From d8ed9185d2216fee8cd65b5b75ae436686eb8ac1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:32:03 +0000 Subject: [PATCH 01/14] Initial plan From e23e96bb1360fe6fe3e438b6ce3d2370a9c45b6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:40:37 +0000 Subject: [PATCH 02/14] feat: add startup app update check flow Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ConfigurationServiceTests.cs | 2 + .../ServicesTests/UpdateServiceTests.cs | 68 ++++++++++++++++ .../ViewModelTests/LoginPageViewModelTests.cs | 68 +++++++++++++++- .../Models/ApplicationUpdateCheckResponse.cs | 15 ++++ .../Models/TokenResponseModel.cs | 2 + .../Services/ConfigurationService.cs | 3 +- .../ConfigurationResponse.cs | 4 +- .../TrainingConfigurationService.cs | 3 +- .../Services/UpdateService.cs | 77 +++++++++++++++++++ .../IApplicationUpdateLauncherService.cs | 7 ++ .../ViewModels/LoginPageViewModel.cs | 49 +++++++++++- .../Extensions/MauiAppBuilderExtensions.cs | 25 ++++-- .../UIServices/ApplicationInfoService.cs | 4 +- .../ApplicationUpdateLauncherService.cs | 25 ++++++ 14 files changed, 337 insertions(+), 15 deletions(-) create mode 100644 TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/UpdateServiceTests.cs create mode 100644 TransactionProcessor.Mobile.BusinessLogic/Models/ApplicationUpdateCheckResponse.cs create mode 100644 TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs create mode 100644 TransactionProcessor.Mobile.BusinessLogic/UIServices/IApplicationUpdateLauncherService.cs create mode 100644 TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/ConfigurationServiceTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/ConfigurationServiceTests.cs index 18324b86f..45541579b 100644 --- a/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/ConfigurationServiceTests.cs +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/ConfigurationServiceTests.cs @@ -35,6 +35,7 @@ public async Task ConfigurationService_GetConfiguration_ResultSuccess_And_Config ConfigurationResponse expectedConfiguration = new ConfigurationResponse { + ApplicationUpdateUri = "http://localhost:9210", ClientId = "clientId", ClientSecret = Guid.NewGuid().ToString(), EnableAutoUpdates = false, @@ -62,6 +63,7 @@ public async Task ConfigurationService_GetConfiguration_ResultSuccess_And_Config configurationResult.Data.ShouldNotBeNull(); configurationResult.Data.ClientSecret.ShouldBe(expectedConfiguration.ClientSecret); configurationResult.Data.ClientId.ShouldBe(expectedConfiguration.ClientId); + configurationResult.Data.ApplicationUpdateUri.ShouldBe(expectedConfiguration.ApplicationUpdateUri); configurationResult.Data.EnableAutoUpdates.ShouldBe(expectedConfiguration.EnableAutoUpdates); configurationResult.Data.SecurityServiceUri.ShouldBe(expectedConfiguration.HostAddresses.Single(s => s.ServiceType == ServiceType.Security).Uri); configurationResult.Data.TransactionProcessorAclUri.ShouldBe(expectedConfiguration.HostAddresses.Single(s => s.ServiceType == ServiceType.TransactionProcessorAcl).Uri); diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/UpdateServiceTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/UpdateServiceTests.cs new file mode 100644 index 000000000..3bc2aed45 --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/UpdateServiceTests.cs @@ -0,0 +1,68 @@ +using Newtonsoft.Json; +using RichardSzalay.MockHttp; +using Shouldly; +using SimpleResults; +using TransactionProcessor.Mobile.BusinessLogic.Logging; +using TransactionProcessor.Mobile.BusinessLogic.Models; +using TransactionProcessor.Mobile.BusinessLogic.Services; + +namespace TransactionProcessor.Mobile.BusinessLogic.Tests.ServicesTests; + +public class UpdateServiceTests +{ + private readonly MockHttpMessageHandler MockHttpMessageHandler; + + private readonly IUpdateService UpdateService; + + public UpdateServiceTests() + { + this.MockHttpMessageHandler = new MockHttpMessageHandler(); + this.UpdateService = new UpdateService(_ => "http://localhost", this.MockHttpMessageHandler.ToHttpClient()); + } + + [Fact] + public async Task UpdateService_CheckForUpdates_ResultSuccess_And_UpdateResponseReturned() + { + Logger.Initialise(new NullLogger()); + + ApplicationUpdateCheckResponse expectedResponse = new() + { + DownloadUri = "https://updates.example.com/transactionmobile.apk", + LatestVersion = "1.0.1", + Message = "Install the latest version.", + UpdateRequired = true + }; + + this.MockHttpMessageHandler.When("http://localhost/api/applicationupdates/check") + .Respond("application/json", JsonConvert.SerializeObject(expectedResponse)); + + Result updateResult = await this.UpdateService.CheckForUpdates(TestData.ApplicationVersion, + "com.transactionprocessor.mobile", + "Android", + TestData.DeviceIdentifier, + CancellationToken.None); + + updateResult.IsSuccess.ShouldBeTrue(); + updateResult.Data.ShouldNotBeNull(); + updateResult.Data.UpdateRequired.ShouldBeTrue(); + updateResult.Data.DownloadUri.ShouldBe(expectedResponse.DownloadUri); + updateResult.Data.LatestVersion.ShouldBe(expectedResponse.LatestVersion); + } + + [Fact] + public async Task UpdateService_CheckForUpdates_FailedHttpCall_ResultFailed() + { + Logger.Initialise(new NullLogger()); + + this.MockHttpMessageHandler.When("http://localhost/api/applicationupdates/check") + .Respond(System.Net.HttpStatusCode.BadRequest); + + Result updateResult = await this.UpdateService.CheckForUpdates(TestData.ApplicationVersion, + "com.transactionprocessor.mobile", + "Android", + TestData.DeviceIdentifier, + CancellationToken.None); + + updateResult.IsFailed.ShouldBeTrue(); + } +} diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs index 57d7711a6..a59464a1e 100644 --- a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs @@ -28,7 +28,11 @@ public class LoginPageViewModelTests private readonly Mock ApplicationInfoService; + private readonly Mock ApplicationUpdateLauncherService; + private readonly Mock DialogService; + + private readonly Mock UpdateService; public LoginPageViewModelTests() { this.Mediator = new Mock(); this.NavigationService = new Mock(); @@ -36,11 +40,14 @@ public LoginPageViewModelTests() { this.ApplicationCache = new Mock(); this.DeviceService = new Mock(); this.ApplicationInfoService = new Mock(); + this.ApplicationUpdateLauncherService = new Mock(); this.DialogService = new Mock(); + this.UpdateService = new Mock(); this.ViewModel = new LoginPageViewModel(this.Mediator.Object, this.NavigationService.Object, this.ApplicationCache.Object, this.DeviceService.Object, this.ApplicationInfoService.Object, - this.DialogService.Object, this.NavigationParameterService.Object); + this.DialogService.Object, this.NavigationParameterService.Object, + this.UpdateService.Object, this.ApplicationUpdateLauncherService.Object); Logger.Initialise(new Logging.NullLogger()); } @@ -127,6 +134,63 @@ public void LoginPageViewModel_LoginCommand_Execute_ErrorGettingToken_WarningToa CancellationToken.None), Times.Once); } + [Fact] + public void LoginPageViewModel_LoginCommand_Execute_UpdateCheckFails_LogonContinues() + { + this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new Configuration { EnableAutoUpdates = true })); + this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Success(TestData.AccessToken)); + this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Success(TestData.PerformLogonResponseModel)); + this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Success(TestData.ContractProductList)); + this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Success(TestData.MerchantBalance)); + this.ApplicationInfoService.Setup(a => a.VersionString).Returns(TestData.ApplicationVersion); + this.ApplicationInfoService.Setup(a => a.PackageName).Returns("com.transactionprocessor.mobile"); + this.DeviceService.Setup(d => d.GetPlatform()).Returns("Android"); + this.DeviceService.Setup(d => d.GetIdentifier()).Returns(TestData.DeviceIdentifier); + this.UpdateService.Setup(u => u.CheckForUpdates(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Failure("Update check failed")); + + this.ViewModel.LogonCommand.Execute(null); + + this.UpdateService.Verify(u => u.CheckForUpdates(TestData.ApplicationVersion, + "com.transactionprocessor.mobile", + "Android", + TestData.DeviceIdentifier, + It.IsAny()), Times.Once); + this.NavigationService.Verify(n => n.GoToHome(), Times.Once); + } + + [Fact] + public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLauncherIsCalled_And_LogonStops() + { + this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new Configuration { EnableAutoUpdates = true })); + this.ApplicationInfoService.Setup(a => a.VersionString).Returns(TestData.ApplicationVersion); + this.ApplicationInfoService.Setup(a => a.PackageName).Returns("com.transactionprocessor.mobile"); + this.DeviceService.Setup(d => d.GetPlatform()).Returns("Android"); + this.DeviceService.Setup(d => d.GetIdentifier()).Returns(TestData.DeviceIdentifier); + this.UpdateService.Setup(u => u.CheckForUpdates(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new ApplicationUpdateCheckResponse + { + DownloadUri = "https://updates.example.com/transactionmobile.apk", + LatestVersion = "1.0.1", + Message = "Install update", + UpdateRequired = true + })); + this.DialogService.Setup(d => d.ShowDialog(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + + this.ViewModel.LogonCommand.Execute(null); + + this.ApplicationUpdateLauncherService.Verify(l => l.LaunchUpdateAsync("https://updates.example.com/transactionmobile.apk", It.IsAny()), Times.Once); + this.Mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); + this.NavigationService.Verify(n => n.GoToHome(), Times.Never); + this.DialogService.Verify(n => n.ShowWarningToast(It.Is(message => message.Contains("update", StringComparison.OrdinalIgnoreCase)), + null, + "OK", + null, + CancellationToken.None), Times.Once); + } + [Fact] public void LoginPageViewModel_LoginCommand_Execute_ErrorDuringLogonTransaction_WarningToastIsShown() { @@ -196,4 +260,4 @@ public void LoginPageViewModel_PropertyTests_ValuesAreAsExpected(){ this.ViewModel.UseTrainingMode.ShouldBeTrue(); this.ViewModel.DeviceIdentifier.ShouldBe("testidentifier"); } -} \ No newline at end of file +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/Models/ApplicationUpdateCheckResponse.cs b/TransactionProcessor.Mobile.BusinessLogic/Models/ApplicationUpdateCheckResponse.cs new file mode 100644 index 000000000..06875b12b --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic/Models/ApplicationUpdateCheckResponse.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +namespace TransactionProcessor.Mobile.BusinessLogic.Models; + +[ExcludeFromCodeCoverage] +public class ApplicationUpdateCheckResponse +{ + public Boolean UpdateRequired { get; set; } + + public String? DownloadUri { get; set; } + + public String? LatestVersion { get; set; } + + public String? Message { get; set; } +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/Models/TokenResponseModel.cs b/TransactionProcessor.Mobile.BusinessLogic/Models/TokenResponseModel.cs index 8e1a2658e..c34632e22 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Models/TokenResponseModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Models/TokenResponseModel.cs @@ -31,6 +31,8 @@ public class Configuration public String EstateReportingUri { get; set; } + public String ApplicationUpdateUri { get; set; } + public LogLevel LogLevel { get; set; } public Boolean EnableAutoUpdates { get; set; } diff --git a/TransactionProcessor.Mobile.BusinessLogic/Services/ConfigurationService.cs b/TransactionProcessor.Mobile.BusinessLogic/Services/ConfigurationService.cs index adb9c897d..0402ea426 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Services/ConfigurationService.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Services/ConfigurationService.cs @@ -76,6 +76,7 @@ public async Task> GetConfiguration(String deviceIdentifie response = new Configuration() { ClientSecret = apiResponse.ClientSecret, ClientId = apiResponse.ClientId, + ApplicationUpdateUri = apiResponse.ApplicationUpdateUri, EnableAutoUpdates = apiResponse.EnableAutoUpdates, SecurityServiceUri = apiResponse.HostAddresses.Single(h => h.ServiceType == ServiceType.Security).Uri, TransactionProcessorAclUri = @@ -125,4 +126,4 @@ public async Task PostDiagnosticLogs(String deviceIdentifier, await this.HandleResponse(httpResponse, cancellationToken); } -} \ No newline at end of file +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/Services/DataTransferObjects/ConfigurationResponse.cs b/TransactionProcessor.Mobile.BusinessLogic/Services/DataTransferObjects/ConfigurationResponse.cs index 68cd6b062..2b3f1740a 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Services/DataTransferObjects/ConfigurationResponse.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Services/DataTransferObjects/ConfigurationResponse.cs @@ -12,9 +12,11 @@ public class ConfigurationResponse public bool EnableAutoUpdates { get; set; } + public string ApplicationUpdateUri { get; set; } + public List HostAddresses { get; set; } public string Id { get; set; } public LoggingLevel LogLevel { get; set; } -} \ No newline at end of file +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/Services/TrainingModeServices/TrainingConfigurationService.cs b/TransactionProcessor.Mobile.BusinessLogic/Services/TrainingModeServices/TrainingConfigurationService.cs index 1d5e42256..944fa1a3a 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Services/TrainingModeServices/TrainingConfigurationService.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Services/TrainingModeServices/TrainingConfigurationService.cs @@ -10,6 +10,7 @@ public class TrainingConfigurationService : IConfigurationService public async Task> GetConfiguration(String deviceIdentifier, CancellationToken cancellationToken) { return Result.Success(new Configuration { + ApplicationUpdateUri = String.Empty, ClientId = "dummyClientId", ClientSecret = "dummyClientSecret", EnableAutoUpdates = false, @@ -27,4 +28,4 @@ public async Task PostDiagnosticLogs(String deviceIdentifier, CancellationToken cancellationToken) { // Do nothing } -} \ No newline at end of file +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs b/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs new file mode 100644 index 000000000..9d50d6502 --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs @@ -0,0 +1,77 @@ +using System.Text; +using Newtonsoft.Json; +using SimpleResults; +using TransactionProcessor.Mobile.BusinessLogic.Models; + +namespace TransactionProcessor.Mobile.BusinessLogic.Services; + +public interface IUpdateService +{ + Task> CheckForUpdates(String applicationVersion, + String packageName, + String platform, + String deviceIdentifier, + CancellationToken cancellationToken); +} + +public class UpdateService : ClientProxyBase.ClientProxyBase, IUpdateService +{ + private readonly Func BaseAddressResolver; + + public UpdateService(Func baseAddressResolver, + HttpClient httpClient) : base(httpClient) + { + this.BaseAddressResolver = baseAddressResolver; + } + + public async Task> CheckForUpdates(String applicationVersion, + String packageName, + String platform, + String deviceIdentifier, + CancellationToken cancellationToken) + { + String requestUri = this.BuildRequestUrl("/api/applicationupdates/check"); + var request = new + { + ApplicationVersion = applicationVersion, + PackageName = packageName, + Platform = platform, + DeviceIdentifier = deviceIdentifier + }; + + try + { + Logger.LogInformation($"About to check for application updates for device identifier {deviceIdentifier}"); + Logger.LogDebug($"Application update request details: Uri {requestUri}"); + + StringContent content = new(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); + HttpResponseMessage httpResponse = await this.HttpClient.PostAsync(requestUri, content, cancellationToken); + Logger.LogDebug($"Application update response [{httpResponse.StatusCode}]"); + + String responseContent = await this.HandleResponse(httpResponse, cancellationToken); + Logger.LogDebug($"Application update response content [{responseContent}]"); + + ApplicationUpdateCheckResponse? response = JsonConvert.DeserializeObject(responseContent); + + if (response == null) + { + return Result.Failure("Application update check did not return a valid response."); + } + + Logger.LogInformation($"Application update check for device identifier {deviceIdentifier} completed successfully"); + return Result.Success(response); + } + catch (Exception ex) + { + Logger.LogError($"Error checking for application updates for device identifier {deviceIdentifier} {ex.Message}.", ex); + return ResultExtensions.FailureExtended($"Error checking for application updates for device identifier {deviceIdentifier}", ex); + } + } + + private String BuildRequestUrl(String route) + { + String baseAddress = this.BaseAddressResolver("ApplicationUpdateServiceUrl"); + + return $"{baseAddress.TrimEnd('/')}{route}"; + } +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/UIServices/IApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile.BusinessLogic/UIServices/IApplicationUpdateLauncherService.cs new file mode 100644 index 000000000..68ef16295 --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic/UIServices/IApplicationUpdateLauncherService.cs @@ -0,0 +1,7 @@ +namespace TransactionProcessor.Mobile.BusinessLogic.UIServices; + +public interface IApplicationUpdateLauncherService +{ + Task LaunchUpdateAsync(String downloadUri, + CancellationToken cancellationToken = default); +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs index c7835c63b..026a4f443 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs @@ -19,6 +19,8 @@ namespace TransactionProcessor.Mobile.BusinessLogic.ViewModels public partial class LoginPageViewModel : ExtendedBaseViewModel { private readonly IApplicationInfoService ApplicationInfoService; + private readonly IApplicationUpdateLauncherService ApplicationUpdateLauncherService; + private readonly IUpdateService UpdateService; private String userName; @@ -33,10 +35,14 @@ public partial class LoginPageViewModel : ExtendedBaseViewModel public LoginPageViewModel(IMediator mediator, INavigationService navigationService, IApplicationCache applicationCache, IDeviceService deviceService,IApplicationInfoService applicationInfoService, IDialogService dialogService, - INavigationParameterService navigationParameterService) : base(applicationCache,dialogService,navigationService, deviceService, navigationParameterService) + INavigationParameterService navigationParameterService, + IUpdateService updateService, + IApplicationUpdateLauncherService applicationUpdateLauncherService) : base(applicationCache,dialogService,navigationService, deviceService, navigationParameterService) { this.ApplicationInfoService = applicationInfoService; + this.ApplicationUpdateLauncherService = applicationUpdateLauncherService; this.Mediator = mediator; + this.UpdateService = updateService; } #endregion @@ -154,6 +160,44 @@ private async Task> GetMerchantBalance() { return getMerchantBalanceResult; } + private async Task CheckForUpdates(Configuration configuration) { + if (configuration?.EnableAutoUpdates != true) { + return; + } + + Result updateCheckResult = await this.UpdateService.CheckForUpdates(this.ApplicationInfoService.VersionString, + this.ApplicationInfoService.PackageName, + this.DeviceService.GetPlatform(), + this.DeviceService.GetIdentifier(), + CancellationToken.None); + + if (updateCheckResult.IsFailed) { + Logger.LogWarning($"Application update check failed: {updateCheckResult.Message}"); + return; + } + + if (updateCheckResult.Data.UpdateRequired == false) { + return; + } + + String message = String.IsNullOrWhiteSpace(updateCheckResult.Data.Message) + ? $"Version {updateCheckResult.Data.LatestVersion ?? "latest"} is available and must be installed before you can continue." + : updateCheckResult.Data.Message; + + Boolean startUpdate = await this.DialogService.ShowDialog("Application Update Required", message, "Install", "Cancel"); + + if (startUpdate == false) { + throw new ApplicationException("An application update is required before you can continue."); + } + + if (String.IsNullOrWhiteSpace(updateCheckResult.Data.DownloadUri)) { + throw new ApplicationException("An application update is required, but no download location is configured."); + } + + await this.ApplicationUpdateLauncherService.LaunchUpdateAsync(updateCheckResult.Data.DownloadUri, CancellationToken.None); + throw new ApplicationException("The application update has started. Complete the installation and reopen the app."); + } + [RelayCommand] private async Task Logon(){ CorrelationIdProvider.NewId(); @@ -167,6 +211,7 @@ private async Task Logon(){ Result configurationResult = await this.GetConfiguration(); this.HandleResult(configurationResult); + await this.CheckForUpdates(configurationResult.Data); await this.WriteTimingTrace(sw, "After GetConfiguration"); Result getTokenResult = await this.GetUserToken(); @@ -241,4 +286,4 @@ private void CacheAccessToken(TokenResponseModel token) #endregion } -} \ No newline at end of file +} diff --git a/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs b/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs index 83e669132..f0cbc20b0 100644 --- a/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs +++ b/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs @@ -90,11 +90,22 @@ public static MauiAppBuilder ConfigureAppServices(this MauiAppBuilder builder) { Configuration configuration = applicationCache.GetConfiguration(); - if (configuration != null) - { - if (configSetting == "SecurityService") - { - return configuration.SecurityServiceUri; + if (configuration != null) + { + if (configSetting == "ApplicationUpdateServiceUrl") + { + if (String.IsNullOrWhiteSpace(configuration.ApplicationUpdateUri) == false) + { + return configuration.ApplicationUpdateUri; + } + + String configHostUrl = applicationCache.GetConfigHostUrl(); + return configHostUrl ?? String.Empty; + } + + if (configSetting == "SecurityService") + { + return configuration.SecurityServiceUri; } if (configSetting == "TransactionProcessorACL") @@ -122,6 +133,7 @@ public static MauiAppBuilder ConfigureAppServices(this MauiAppBuilder builder) { builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton>(new Func(useTrainingMode => { @@ -211,6 +223,7 @@ public static MauiAppBuilder ConfigureUIServices(this MauiAppBuilder builder) { builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); return builder; } @@ -351,4 +364,4 @@ protected override IHostnameVerifier GetSSLHostnameVerifier(HttpsURLConnection c } } #endif -} \ No newline at end of file +} diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationInfoService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationInfoService.cs index 3a6bc72c1..3ab7931ad 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationInfoService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationInfoService.cs @@ -15,5 +15,5 @@ public class ApplicationInfoService : IApplicationInfoService public Version Version => AppInfo.Version; - public String VersionString => "1.0.0";//AppInfo.VersionString; -} \ No newline at end of file + public String VersionString => AppInfo.VersionString; +} diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs new file mode 100644 index 000000000..4f0a27fb9 --- /dev/null +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -0,0 +1,25 @@ +using Microsoft.Maui.ApplicationModel; +using TransactionProcessor.Mobile.BusinessLogic.UIServices; + +namespace TransactionProcessor.Mobile.UIServices; + +public class ApplicationUpdateLauncherService : IApplicationUpdateLauncherService +{ + public async Task LaunchUpdateAsync(String downloadUri, + CancellationToken cancellationToken = default) + { + if (String.IsNullOrWhiteSpace(downloadUri)) + { + throw new ArgumentException("A download URI is required to launch an application update.", nameof(downloadUri)); + } + + Uri updateUri = new(downloadUri, UriKind.Absolute); + + if (await Launcher.Default.CanOpenAsync(updateUri) == false) + { + throw new InvalidOperationException($"The application update address '{downloadUri}' cannot be opened on this device."); + } + + await Launcher.Default.OpenAsync(updateUri); + } +} From 26ebc813332fdb740ed29716e2d4f18748ed4a33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:30:25 +0000 Subject: [PATCH 03/14] fix: quit app after launching update installer Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ViewModelTests/LoginPageViewModelTests.cs | 7 ++++--- .../ViewModels/LoginPageViewModel.cs | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs index a59464a1e..641091f89 100644 --- a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs @@ -161,7 +161,7 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateCheckFails_LogonContin } [Fact] - public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLauncherIsCalled_And_LogonStops() + public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLauncherIsCalled_And_AppQuits() { this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success(new Configuration { EnableAutoUpdates = true })); @@ -182,13 +182,14 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLaunche this.ViewModel.LogonCommand.Execute(null); this.ApplicationUpdateLauncherService.Verify(l => l.LaunchUpdateAsync("https://updates.example.com/transactionmobile.apk", It.IsAny()), Times.Once); + this.NavigationService.Verify(n => n.QuitApplication(), Times.Once); this.Mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); this.NavigationService.Verify(n => n.GoToHome(), Times.Never); - this.DialogService.Verify(n => n.ShowWarningToast(It.Is(message => message.Contains("update", StringComparison.OrdinalIgnoreCase)), + this.DialogService.Verify(n => n.ShowWarningToast(It.IsAny(), null, "OK", null, - CancellationToken.None), Times.Once); + CancellationToken.None), Times.Never); } [Fact] diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs index 026a4f443..e4baf5631 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs @@ -181,7 +181,7 @@ private async Task CheckForUpdates(Configuration configuration) { } String message = String.IsNullOrWhiteSpace(updateCheckResult.Data.Message) - ? $"Version {updateCheckResult.Data.LatestVersion ?? "latest"} is available and must be installed before you can continue." + ? $"Version {updateCheckResult.Data.LatestVersion ?? "latest"} is available and must be installed before you can continue. The installer will open and the app will close." : updateCheckResult.Data.Message; Boolean startUpdate = await this.DialogService.ShowDialog("Application Update Required", message, "Install", "Cancel"); @@ -195,7 +195,7 @@ private async Task CheckForUpdates(Configuration configuration) { } await this.ApplicationUpdateLauncherService.LaunchUpdateAsync(updateCheckResult.Data.DownloadUri, CancellationToken.None); - throw new ApplicationException("The application update has started. Complete the installation and reopen the app."); + await this.NavigationService.QuitApplication(); } [RelayCommand] From 2fbac405340560f8a6b639f5de6fb8f87de5898b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:11:15 +0000 Subject: [PATCH 04/14] fix: open http update links in browser Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/954c0255-bcb4-49da-b52b-a870dc25f39e Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../UIServices/ApplicationUpdateLauncherService.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs index 4f0a27fb9..9c64e5821 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -15,6 +15,12 @@ public async Task LaunchUpdateAsync(String downloadUri, Uri updateUri = new(downloadUri, UriKind.Absolute); + if (updateUri.Scheme == Uri.UriSchemeHttp || updateUri.Scheme == Uri.UriSchemeHttps) + { + await Browser.Default.OpenAsync(updateUri, BrowserLaunchMode.External); + return; + } + if (await Launcher.Default.CanOpenAsync(updateUri) == false) { throw new InvalidOperationException($"The application update address '{downloadUri}' cannot be opened on this device."); From 974f0bb382dab3459faa033e7b34abc2d16bb206 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:31:44 +0000 Subject: [PATCH 05/14] Implement Android in-app update installer flow Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/90c3f8e9-c90d-4b1e-b1b9-3672e1b0a73e Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ViewModelTests/LoginPageViewModelTests.cs | 37 ++++ .../ViewModels/LoginPageViewModel.cs | 14 +- .../Pages/LoginPage.xaml | 196 ++++++++++-------- .../Platforms/Android/AndroidManifest.xml | 15 +- .../Resources/xml/update_file_paths.xml | 4 + .../ApplicationUpdateLauncherService.cs | 140 ++++++++++++- 6 files changed, 312 insertions(+), 94 deletions(-) create mode 100644 TransactionProcessor.Mobile/Platforms/Android/Resources/xml/update_file_paths.xml diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs index 641091f89..0dcff1e36 100644 --- a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs @@ -181,6 +181,11 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLaunche this.ViewModel.LogonCommand.Execute(null); + this.DialogService.Verify(d => d.ShowInformationToast("Downloading the required update...", + null, + "OK", + null, + CancellationToken.None), Times.Once); this.ApplicationUpdateLauncherService.Verify(l => l.LaunchUpdateAsync("https://updates.example.com/transactionmobile.apk", It.IsAny()), Times.Once); this.NavigationService.Verify(n => n.QuitApplication(), Times.Once); this.Mediator.Verify(x => x.Send(It.IsAny(), It.IsAny()), Times.Never); @@ -192,6 +197,38 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLaunche CancellationToken.None), Times.Never); } + [Fact] + public void LoginPageViewModel_LoginCommand_Execute_UpdateLauncherFails_WarningToastIsShown_And_AppStaysOpen() + { + this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new Configuration { EnableAutoUpdates = true })); + this.ApplicationInfoService.Setup(a => a.VersionString).Returns(TestData.ApplicationVersion); + this.ApplicationInfoService.Setup(a => a.PackageName).Returns("com.transactionprocessor.mobile"); + this.DeviceService.Setup(d => d.GetPlatform()).Returns("Android"); + this.DeviceService.Setup(d => d.GetIdentifier()).Returns(TestData.DeviceIdentifier); + this.UpdateService.Setup(u => u.CheckForUpdates(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(new ApplicationUpdateCheckResponse + { + DownloadUri = "https://updates.example.com/transactionmobile.apk", + LatestVersion = "1.0.1", + Message = "Install update", + UpdateRequired = true + })); + this.DialogService.Setup(d => d.ShowDialog(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); + this.ApplicationUpdateLauncherService.Setup(l => l.LaunchUpdateAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new ApplicationException("Unable to start the application update installer.")); + + this.ViewModel.LogonCommand.Execute(null); + + this.NavigationService.Verify(n => n.QuitApplication(), Times.Never); + this.NavigationService.Verify(n => n.GoToHome(), Times.Never); + this.DialogService.Verify(d => d.ShowWarningToast("Unable to start the application update installer.", + null, + "OK", + null, + CancellationToken.None), Times.Once); + } + [Fact] public void LoginPageViewModel_LoginCommand_Execute_ErrorDuringLogonTransaction_WarningToastIsShown() { diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs index e4baf5631..c2ad399e5 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs @@ -194,8 +194,18 @@ private async Task CheckForUpdates(Configuration configuration) { throw new ApplicationException("An application update is required, but no download location is configured."); } - await this.ApplicationUpdateLauncherService.LaunchUpdateAsync(updateCheckResult.Data.DownloadUri, CancellationToken.None); - await this.NavigationService.QuitApplication(); + this.IsBusy = true; + + try + { + await this.DialogService.ShowInformationToast("Downloading the required update..."); + await this.ApplicationUpdateLauncherService.LaunchUpdateAsync(updateCheckResult.Data.DownloadUri, CancellationToken.None); + await this.NavigationService.QuitApplication(); + } + finally + { + this.IsBusy = false; + } } [RelayCommand] diff --git a/TransactionProcessor.Mobile/Pages/LoginPage.xaml b/TransactionProcessor.Mobile/Pages/LoginPage.xaml index 2d41640c5..9cd0bf284 100644 --- a/TransactionProcessor.Mobile/Pages/LoginPage.xaml +++ b/TransactionProcessor.Mobile/Pages/LoginPage.xaml @@ -15,103 +15,123 @@ - - + + + - - - - + + + + - - - + + + - - + + + + + + + + + - - - + + diff --git a/TransactionProcessor.Mobile/Platforms/Android/AndroidManifest.xml b/TransactionProcessor.Mobile/Platforms/Android/AndroidManifest.xml index 2b234d6b9..8e2967435 100644 --- a/TransactionProcessor.Mobile/Platforms/Android/AndroidManifest.xml +++ b/TransactionProcessor.Mobile/Platforms/Android/AndroidManifest.xml @@ -1,6 +1,17 @@  - + + + + + - \ No newline at end of file + + diff --git a/TransactionProcessor.Mobile/Platforms/Android/Resources/xml/update_file_paths.xml b/TransactionProcessor.Mobile/Platforms/Android/Resources/xml/update_file_paths.xml new file mode 100644 index 000000000..ddedb66ff --- /dev/null +++ b/TransactionProcessor.Mobile/Platforms/Android/Resources/xml/update_file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs index 9c64e5821..a05d460ea 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -1,19 +1,44 @@ using Microsoft.Maui.ApplicationModel; using TransactionProcessor.Mobile.BusinessLogic.UIServices; +#if ANDROID +using Android.Content; +using Android.OS; +using Android.Provider; +using AndroidX.Core.Content; +using Java.IO; +#endif namespace TransactionProcessor.Mobile.UIServices; public class ApplicationUpdateLauncherService : IApplicationUpdateLauncherService { + private readonly IHttpClientFactory HttpClientFactory; + + public ApplicationUpdateLauncherService(IHttpClientFactory httpClientFactory) + { + this.HttpClientFactory = httpClientFactory; + } + public async Task LaunchUpdateAsync(String downloadUri, CancellationToken cancellationToken = default) { if (String.IsNullOrWhiteSpace(downloadUri)) { - throw new ArgumentException("A download URI is required to launch an application update.", nameof(downloadUri)); + throw new ApplicationException("An application update is required, but the download address is invalid."); } - Uri updateUri = new(downloadUri, UriKind.Absolute); + if (Uri.TryCreate(downloadUri, UriKind.Absolute, out Uri? updateUri) == false) + { + throw new ApplicationException("An application update is required, but the download address is invalid."); + } + +#if ANDROID + if (OperatingSystem.IsAndroid()) + { + await this.LaunchAndroidUpdateAsync(updateUri, cancellationToken); + return; + } +#endif if (updateUri.Scheme == Uri.UriSchemeHttp || updateUri.Scheme == Uri.UriSchemeHttps) { @@ -28,4 +53,115 @@ public async Task LaunchUpdateAsync(String downloadUri, await Launcher.Default.OpenAsync(updateUri); } + +#if ANDROID + private async Task LaunchAndroidUpdateAsync(Uri updateUri, + CancellationToken cancellationToken) + { + if (updateUri.Scheme != Uri.UriSchemeHttp && updateUri.Scheme != Uri.UriSchemeHttps) + { + throw new ApplicationException("Android updates require a valid HTTP or HTTPS download address."); + } + + try + { + Context context = Platform.AppContext ?? Android.App.Application.Context; + + if (Build.VERSION.SdkInt >= BuildVersionCodes.O && context.PackageManager?.CanRequestPackageInstalls() != true) + { + Intent settingsIntent = new(Settings.ActionManageUnknownAppSources); + settingsIntent.SetData(Android.Net.Uri.Parse($"package:{context.PackageName}")); + settingsIntent.AddFlags(ActivityFlags.NewTask); + context.StartActivity(settingsIntent); + + throw new ApplicationException("Allow installs from this app, then retry the update."); + } + + String updateFilePath = await this.DownloadUpdatePackageAsync(updateUri, cancellationToken); + File updateFile = new(updateFilePath); + + if (updateFile.Exists() == false) + { + throw new ApplicationException("The update package could not be prepared for installation."); + } + + Android.Net.Uri installerUri = FileProvider.GetUriForFile(context, $"{context.PackageName}.fileprovider", updateFile); + + Intent installIntent = new(Intent.ActionInstallPackage); + installIntent.SetData(installerUri); + installIntent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.NewTask); + installIntent.PutExtra(Intent.ExtraReturnResult, false); + + if (installIntent.ResolveActivity(context.PackageManager) == null) + { + throw new ApplicationException("No installer is available on this device to open the update package."); + } + + context.StartActivity(installIntent); + } + catch (ApplicationException) + { + throw; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + throw new ApplicationException("Unable to start the application update installer.", ex); + } + } + + private async Task DownloadUpdatePackageAsync(Uri updateUri, + CancellationToken cancellationToken) + { + String updatesDirectory = Path.Combine(FileSystem.CacheDirectory, "updates"); + Directory.CreateDirectory(updatesDirectory); + + String fileName = this.GetUpdateFileName(updateUri); + String updateFilePath = Path.Combine(updatesDirectory, fileName); + + if (System.IO.File.Exists(updateFilePath)) + { + System.IO.File.Delete(updateFilePath); + } + + HttpClient httpClient = this.HttpClientFactory.CreateClient("default"); + using HttpResponseMessage response = await httpClient.GetAsync(updateUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + + if (response.IsSuccessStatusCode == false) + { + throw new ApplicationException($"The update package could not be downloaded ({(Int32)response.StatusCode} {response.ReasonPhrase})."); + } + + await using Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await using FileStream destinationStream = new(updateFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); + await responseStream.CopyToAsync(destinationStream, cancellationToken); + + return updateFilePath; + } + + private String GetUpdateFileName(Uri updateUri) + { + String fileName = Path.GetFileName(updateUri.LocalPath); + + if (String.IsNullOrWhiteSpace(fileName)) + { + return "transactionprocessor_mobile_update.apk"; + } + + foreach (Char invalidCharacter in Path.GetInvalidFileNameChars()) + { + fileName = fileName.Replace(invalidCharacter, '_'); + } + + if (fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) == false) + { + fileName = $"{fileName}.apk"; + } + + return fileName; + } +#endif } From 17c7f4769066c0250e8900c9ebb45b27627ffb11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:32:50 +0000 Subject: [PATCH 06/14] Polish Android update installer flow Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/90c3f8e9-c90d-4b1e-b1b9-3672e1b0a73e Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../UIServices/ApplicationUpdateLauncherService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs index a05d460ea..66c36f948 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -12,6 +12,7 @@ namespace TransactionProcessor.Mobile.UIServices; public class ApplicationUpdateLauncherService : IApplicationUpdateLauncherService { + private const Int32 DownloadBufferSize = 81920; private readonly IHttpClientFactory HttpClientFactory; public ApplicationUpdateLauncherService(IHttpClientFactory httpClientFactory) @@ -136,7 +137,7 @@ private async Task DownloadUpdatePackageAsync(Uri updateUri, } await using Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); - await using FileStream destinationStream = new(updateFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); + await using FileStream destinationStream = new(updateFilePath, FileMode.Create, FileAccess.Write, FileShare.None, DownloadBufferSize, true); await responseStream.CopyToAsync(destinationStream, cancellationToken); return updateFilePath; From 1c0bc9da8967b5a6b05033c5a78c8954f0ef19dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:43:47 +0000 Subject: [PATCH 07/14] Fix launcher type ambiguities Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/1ec96201-2bab-450e-b455-015a385f709b Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../UIServices/ApplicationUpdateLauncherService.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs index 66c36f948..613d3fa22 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -1,11 +1,14 @@ -using Microsoft.Maui.ApplicationModel; +using Browser = Microsoft.Maui.ApplicationModel.Browser; +using OperationCanceledException = System.OperationCanceledException; +using Microsoft.Maui.ApplicationModel; using TransactionProcessor.Mobile.BusinessLogic.UIServices; #if ANDROID using Android.Content; using Android.OS; using Android.Provider; using AndroidX.Core.Content; -using Java.IO; +using JavaFile = Java.IO.File; +using MauiFileProvider = AndroidX.Core.Content.FileProvider; #endif namespace TransactionProcessor.Mobile.UIServices; @@ -79,14 +82,14 @@ private async Task LaunchAndroidUpdateAsync(Uri updateUri, } String updateFilePath = await this.DownloadUpdatePackageAsync(updateUri, cancellationToken); - File updateFile = new(updateFilePath); + JavaFile updateFile = new(updateFilePath); if (updateFile.Exists() == false) { throw new ApplicationException("The update package could not be prepared for installation."); } - Android.Net.Uri installerUri = FileProvider.GetUriForFile(context, $"{context.PackageName}.fileprovider", updateFile); + Android.Net.Uri installerUri = MauiFileProvider.GetUriForFile(context, $"{context.PackageName}.fileprovider", updateFile); Intent installIntent = new(Intent.ActionInstallPackage); installIntent.SetData(installerUri); From b4e6b823e5b0e827554148ea650623d9163f12bd Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Fri, 3 Apr 2026 13:00:54 +0100 Subject: [PATCH 08/14] minor tweaks --- .../Services/UpdateService.cs | 1 + .../Extensions/MauiAppBuilderExtensions.cs | 19 +++++++++---------- .../TransactionProcessor.Mobile.csproj | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs b/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs index 9d50d6502..6c4ff317d 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs @@ -1,6 +1,7 @@ using System.Text; using Newtonsoft.Json; using SimpleResults; +using TransactionProcessor.Mobile.BusinessLogic.Logging; using TransactionProcessor.Mobile.BusinessLogic.Models; namespace TransactionProcessor.Mobile.BusinessLogic.Services; diff --git a/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs b/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs index 073bc44b6..9b51cd1d3 100644 --- a/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs +++ b/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs @@ -92,16 +92,15 @@ public static MauiAppBuilder ConfigureAppServices(this MauiAppBuilder builder) { if (configuration != null) { - if (configSetting == "ApplicationUpdateServiceUrl") - { - if (String.IsNullOrWhiteSpace(configuration.ApplicationUpdateUri) == false) - { - return configuration.ApplicationUpdateUri; - } - - String configHostUrl = applicationCache.GetConfigHostUrl(); - return configHostUrl ?? String.Empty; - } + if (configSetting == "ApplicationUpdateServiceUrl") { + if (String.IsNullOrWhiteSpace(configuration.ApplicationUpdateUri) == false) + { + return configuration.ApplicationUpdateUri; + } + + String configHostUrl = applicationCache.GetConfigHostUrl(); + return configHostUrl ?? String.Empty; + } if (configSetting == "SecurityService") { diff --git a/TransactionProcessor.Mobile/TransactionProcessor.Mobile.csproj b/TransactionProcessor.Mobile/TransactionProcessor.Mobile.csproj index 9a7d052a8..14083e6df 100644 --- a/TransactionProcessor.Mobile/TransactionProcessor.Mobile.csproj +++ b/TransactionProcessor.Mobile/TransactionProcessor.Mobile.csproj @@ -23,7 +23,7 @@ com.transactionprocessor.mobile - 1.0 + 1.0.0 1 From 555b325080d16e7c3e4c60ac7f23900bb0302c19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:20:23 +0000 Subject: [PATCH 09/14] fix: address codacy issues in update flow Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/c5f842a4-c058-4ae2-8b97-fcdb17ce7a74 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Services/UpdateService.cs | 4 +-- .../ApplicationUpdateLauncherService.cs | 27 +++++-------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs b/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs index 6c4ff317d..29a74bc88 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs @@ -45,8 +45,8 @@ public async Task> CheckForUpdates(String Logger.LogInformation($"About to check for application updates for device identifier {deviceIdentifier}"); Logger.LogDebug($"Application update request details: Uri {requestUri}"); - StringContent content = new(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); - HttpResponseMessage httpResponse = await this.HttpClient.PostAsync(requestUri, content, cancellationToken); + using StringContent content = new(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json"); + using HttpResponseMessage httpResponse = await this.HttpClient.PostAsync(requestUri, content, cancellationToken); Logger.LogDebug($"Application update response [{httpResponse.StatusCode}]"); String responseContent = await this.HandleResponse(httpResponse, cancellationToken); diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs index 613d3fa22..67202c89f 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -4,7 +4,6 @@ using TransactionProcessor.Mobile.BusinessLogic.UIServices; #if ANDROID using Android.Content; -using Android.OS; using Android.Provider; using AndroidX.Core.Content; using JavaFile = Java.IO.File; @@ -16,6 +15,7 @@ namespace TransactionProcessor.Mobile.UIServices; public class ApplicationUpdateLauncherService : IApplicationUpdateLauncherService { private const Int32 DownloadBufferSize = 81920; + private const String AndroidPackageArchiveMimeType = "application/vnd.android.package-archive"; private readonly IHttpClientFactory HttpClientFactory; public ApplicationUpdateLauncherService(IHttpClientFactory httpClientFactory) @@ -26,14 +26,9 @@ public ApplicationUpdateLauncherService(IHttpClientFactory httpClientFactory) public async Task LaunchUpdateAsync(String downloadUri, CancellationToken cancellationToken = default) { - if (String.IsNullOrWhiteSpace(downloadUri)) - { - throw new ApplicationException("An application update is required, but the download address is invalid."); - } - if (Uri.TryCreate(downloadUri, UriKind.Absolute, out Uri? updateUri) == false) { - throw new ApplicationException("An application update is required, but the download address is invalid."); + throw new ArgumentException("An application update is required, but the download address is invalid.", nameof(downloadUri)); } #if ANDROID @@ -64,14 +59,14 @@ private async Task LaunchAndroidUpdateAsync(Uri updateUri, { if (updateUri.Scheme != Uri.UriSchemeHttp && updateUri.Scheme != Uri.UriSchemeHttps) { - throw new ApplicationException("Android updates require a valid HTTP or HTTPS download address."); + throw new ArgumentException("Android updates require a valid HTTP or HTTPS download address.", nameof(updateUri)); } try { Context context = Platform.AppContext ?? Android.App.Application.Context; - if (Build.VERSION.SdkInt >= BuildVersionCodes.O && context.PackageManager?.CanRequestPackageInstalls() != true) + if (OperatingSystem.IsAndroidVersionAtLeast(26) && context.PackageManager?.CanRequestPackageInstalls() != true) { Intent settingsIntent = new(Settings.ActionManageUnknownAppSources); settingsIntent.SetData(Android.Net.Uri.Parse($"package:{context.PackageName}")); @@ -91,8 +86,8 @@ private async Task LaunchAndroidUpdateAsync(Uri updateUri, Android.Net.Uri installerUri = MauiFileProvider.GetUriForFile(context, $"{context.PackageName}.fileprovider", updateFile); - Intent installIntent = new(Intent.ActionInstallPackage); - installIntent.SetData(installerUri); + Intent installIntent = new(Intent.ActionView); + installIntent.SetDataAndType(installerUri, AndroidPackageArchiveMimeType); installIntent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.NewTask); installIntent.PutExtra(Intent.ExtraReturnResult, false); @@ -103,15 +98,7 @@ private async Task LaunchAndroidUpdateAsync(Uri updateUri, context.StartActivity(installIntent); } - catch (ApplicationException) - { - throw; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) + catch (Exception ex) when (ex is not ApplicationException && ex is not ArgumentException && ex is not OperationCanceledException) { throw new ApplicationException("Unable to start the application update installer.", ex); } From cdd63026a88cebded496a41b3d7e38186e29d35e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:23:58 +0000 Subject: [PATCH 10/14] fix: tighten update launcher validation Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/c5f842a4-c058-4ae2-8b97-fcdb17ce7a74 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ApplicationUpdateLauncherService.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs index 67202c89f..a334eddb1 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -26,6 +26,8 @@ public ApplicationUpdateLauncherService(IHttpClientFactory httpClientFactory) public async Task LaunchUpdateAsync(String downloadUri, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrWhiteSpace(downloadUri); + if (Uri.TryCreate(downloadUri, UriKind.Absolute, out Uri? updateUri) == false) { throw new ArgumentException("An application update is required, but the download address is invalid.", nameof(downloadUri)); @@ -86,8 +88,19 @@ private async Task LaunchAndroidUpdateAsync(Uri updateUri, Android.Net.Uri installerUri = MauiFileProvider.GetUriForFile(context, $"{context.PackageName}.fileprovider", updateFile); - Intent installIntent = new(Intent.ActionView); - installIntent.SetDataAndType(installerUri, AndroidPackageArchiveMimeType); + Intent installIntent = OperatingSystem.IsAndroidVersionAtLeast(29) + ? new Intent(Intent.ActionView) + : new Intent(Intent.ActionInstallPackage); + + if (OperatingSystem.IsAndroidVersionAtLeast(29)) + { + installIntent.SetDataAndType(installerUri, AndroidPackageArchiveMimeType); + } + else + { + installIntent.SetData(installerUri); + } + installIntent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.NewTask); installIntent.PutExtra(Intent.ExtraReturnResult, false); From 03dfa0f6355b8be99a9a4b4f6fedc6b2606eec5b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:49:53 +0000 Subject: [PATCH 11/14] fix: await async update login tests Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/2e70d176-355f-44ac-9b7f-744249fd14f2 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ViewModelTests/LoginPageViewModelTests.cs | 12 +-- .../Services/UpdateService.cs | 13 ++- .../ViewModels/LoginPageViewModel.cs | 58 +++++++++---- .../ApplicationUpdateLauncherService.cs | 84 +++++++++++-------- 4 files changed, 102 insertions(+), 65 deletions(-) diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs index 0dcff1e36..fb2240914 100644 --- a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/LoginPageViewModelTests.cs @@ -135,7 +135,7 @@ public void LoginPageViewModel_LoginCommand_Execute_ErrorGettingToken_WarningToa } [Fact] - public void LoginPageViewModel_LoginCommand_Execute_UpdateCheckFails_LogonContinues() + public async Task LoginPageViewModel_LoginCommand_Execute_UpdateCheckFails_LogonContinues() { this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success(new Configuration { EnableAutoUpdates = true })); @@ -150,7 +150,7 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateCheckFails_LogonContin this.UpdateService.Setup(u => u.CheckForUpdates(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Failure("Update check failed")); - this.ViewModel.LogonCommand.Execute(null); + await this.ViewModel.LogonCommand.ExecuteAsync(null); this.UpdateService.Verify(u => u.CheckForUpdates(TestData.ApplicationVersion, "com.transactionprocessor.mobile", @@ -161,7 +161,7 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateCheckFails_LogonContin } [Fact] - public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLauncherIsCalled_And_AppQuits() + public async Task LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLauncherIsCalled_And_AppQuits() { this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success(new Configuration { EnableAutoUpdates = true })); @@ -179,7 +179,7 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLaunche })); this.DialogService.Setup(d => d.ShowDialog(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); - this.ViewModel.LogonCommand.Execute(null); + await this.ViewModel.LogonCommand.ExecuteAsync(null); this.DialogService.Verify(d => d.ShowInformationToast("Downloading the required update...", null, @@ -198,7 +198,7 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateRequired_UpdateLaunche } [Fact] - public void LoginPageViewModel_LoginCommand_Execute_UpdateLauncherFails_WarningToastIsShown_And_AppStaysOpen() + public async Task LoginPageViewModel_LoginCommand_Execute_UpdateLauncherFails_WarningToastIsShown_And_AppStaysOpen() { this.Mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success(new Configuration { EnableAutoUpdates = true })); @@ -218,7 +218,7 @@ public void LoginPageViewModel_LoginCommand_Execute_UpdateLauncherFails_WarningT this.ApplicationUpdateLauncherService.Setup(l => l.LaunchUpdateAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new ApplicationException("Unable to start the application update installer.")); - this.ViewModel.LogonCommand.Execute(null); + await this.ViewModel.LogonCommand.ExecuteAsync(null); this.NavigationService.Verify(n => n.QuitApplication(), Times.Never); this.NavigationService.Verify(n => n.GoToHome(), Times.Never); diff --git a/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs b/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs index 29a74bc88..0f2db93dd 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Services/UpdateService.cs @@ -17,6 +17,11 @@ Task> CheckForUpdates(String applicationV public class UpdateService : ClientProxyBase.ClientProxyBase, IUpdateService { + private sealed record ApplicationUpdateCheckRequest(String ApplicationVersion, + String PackageName, + String Platform, + String DeviceIdentifier); + private readonly Func BaseAddressResolver; public UpdateService(Func baseAddressResolver, @@ -32,13 +37,7 @@ public async Task> CheckForUpdates(String CancellationToken cancellationToken) { String requestUri = this.BuildRequestUrl("/api/applicationupdates/check"); - var request = new - { - ApplicationVersion = applicationVersion, - PackageName = packageName, - Platform = platform, - DeviceIdentifier = deviceIdentifier - }; + ApplicationUpdateCheckRequest request = new(applicationVersion, packageName, platform, deviceIdentifier); try { diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs index c2ad399e5..5c756ef01 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs @@ -83,7 +83,7 @@ public String ConfigHostUrl private void CacheUseTrainingMode() => this.ApplicationCache.SetUseTrainingMode(this.useTrainingMode); private async Task> GetConfiguration() { - if (String.IsNullOrEmpty(this.ConfigHostUrl) == false) { + if (!String.IsNullOrEmpty(this.ConfigHostUrl)) { this.ApplicationCache.SetConfigHostUrl(this.ConfigHostUrl); } @@ -161,45 +161,67 @@ private async Task> GetMerchantBalance() { } private async Task CheckForUpdates(Configuration configuration) { - if (configuration?.EnableAutoUpdates != true) { + if (!this.ShouldCheckForUpdates(configuration)) { return; } Result updateCheckResult = await this.UpdateService.CheckForUpdates(this.ApplicationInfoService.VersionString, - this.ApplicationInfoService.PackageName, - this.DeviceService.GetPlatform(), - this.DeviceService.GetIdentifier(), - CancellationToken.None); + this.ApplicationInfoService.PackageName, + this.DeviceService.GetPlatform(), + this.DeviceService.GetIdentifier(), + CancellationToken.None); - if (updateCheckResult.IsFailed) { - Logger.LogWarning($"Application update check failed: {updateCheckResult.Message}"); + if (!this.IsUpdateRequired(updateCheckResult)) { return; } - if (updateCheckResult.Data.UpdateRequired == false) { - return; + ApplicationUpdateCheckResponse updateResponse = updateCheckResult.Data; + String message = this.BuildUpdateMessage(updateResponse); + Boolean startUpdate = await this.DialogService.ShowDialog("Application Update Required", message, "Install", "Cancel"); + + if (!startUpdate) { + throw new ApplicationException("An application update is required before you can continue."); } - String message = String.IsNullOrWhiteSpace(updateCheckResult.Data.Message) - ? $"Version {updateCheckResult.Data.LatestVersion ?? "latest"} is available and must be installed before you can continue. The installer will open and the app will close." - : updateCheckResult.Data.Message; + String downloadUri = this.GetRequiredDownloadUri(updateResponse); - Boolean startUpdate = await this.DialogService.ShowDialog("Application Update Required", message, "Install", "Cancel"); + await this.LaunchRequiredUpdate(downloadUri); + } - if (startUpdate == false) { - throw new ApplicationException("An application update is required before you can continue."); + private Boolean ShouldCheckForUpdates(Configuration configuration) => configuration?.EnableAutoUpdates is true; + + private Boolean IsUpdateRequired(Result updateCheckResult) + { + if (updateCheckResult.IsFailed) { + Logger.LogWarning($"Application update check failed: {updateCheckResult.Message}"); + return false; } - if (String.IsNullOrWhiteSpace(updateCheckResult.Data.DownloadUri)) { + return updateCheckResult.Data.UpdateRequired; + } + + private String BuildUpdateMessage(ApplicationUpdateCheckResponse updateResponse) => + String.IsNullOrWhiteSpace(updateResponse.Message) + ? $"Version {updateResponse.LatestVersion ?? "latest"} is available and must be installed before you can continue. The installer will open and the app will close." + : updateResponse.Message; + + private String GetRequiredDownloadUri(ApplicationUpdateCheckResponse updateResponse) + { + if (String.IsNullOrWhiteSpace(updateResponse.DownloadUri)) { throw new ApplicationException("An application update is required, but no download location is configured."); } + return updateResponse.DownloadUri; + } + + private async Task LaunchRequiredUpdate(String downloadUri) + { this.IsBusy = true; try { await this.DialogService.ShowInformationToast("Downloading the required update..."); - await this.ApplicationUpdateLauncherService.LaunchUpdateAsync(updateCheckResult.Data.DownloadUri, CancellationToken.None); + await this.ApplicationUpdateLauncherService.LaunchUpdateAsync(downloadUri, CancellationToken.None); await this.NavigationService.QuitApplication(); } finally diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs index a334eddb1..7460cddde 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -28,7 +28,7 @@ public async Task LaunchUpdateAsync(String downloadUri, { ArgumentException.ThrowIfNullOrWhiteSpace(downloadUri); - if (Uri.TryCreate(downloadUri, UriKind.Absolute, out Uri? updateUri) == false) + if (!Uri.TryCreate(downloadUri, UriKind.Absolute, out Uri? updateUri)) { throw new ArgumentException("An application update is required, but the download address is invalid.", nameof(downloadUri)); } @@ -47,7 +47,7 @@ public async Task LaunchUpdateAsync(String downloadUri, return; } - if (await Launcher.Default.CanOpenAsync(updateUri) == false) + if (!await Launcher.Default.CanOpenAsync(updateUri)) { throw new InvalidOperationException($"The application update address '{downloadUri}' cannot be opened on this device."); } @@ -59,52 +59,27 @@ public async Task LaunchUpdateAsync(String downloadUri, private async Task LaunchAndroidUpdateAsync(Uri updateUri, CancellationToken cancellationToken) { - if (updateUri.Scheme != Uri.UriSchemeHttp && updateUri.Scheme != Uri.UriSchemeHttps) - { - throw new ArgumentException("Android updates require a valid HTTP or HTTPS download address.", nameof(updateUri)); - } + this.ValidateAndroidUpdateUri(updateUri); try { Context context = Platform.AppContext ?? Android.App.Application.Context; - - if (OperatingSystem.IsAndroidVersionAtLeast(26) && context.PackageManager?.CanRequestPackageInstalls() != true) - { - Intent settingsIntent = new(Settings.ActionManageUnknownAppSources); - settingsIntent.SetData(Android.Net.Uri.Parse($"package:{context.PackageName}")); - settingsIntent.AddFlags(ActivityFlags.NewTask); - context.StartActivity(settingsIntent); - - throw new ApplicationException("Allow installs from this app, then retry the update."); - } + this.EnsureInstallPermission(context); String updateFilePath = await this.DownloadUpdatePackageAsync(updateUri, cancellationToken); JavaFile updateFile = new(updateFilePath); - if (updateFile.Exists() == false) + if (!updateFile.Exists()) { throw new ApplicationException("The update package could not be prepared for installation."); } Android.Net.Uri installerUri = MauiFileProvider.GetUriForFile(context, $"{context.PackageName}.fileprovider", updateFile); - - Intent installIntent = OperatingSystem.IsAndroidVersionAtLeast(29) - ? new Intent(Intent.ActionView) - : new Intent(Intent.ActionInstallPackage); - - if (OperatingSystem.IsAndroidVersionAtLeast(29)) - { - installIntent.SetDataAndType(installerUri, AndroidPackageArchiveMimeType); - } - else - { - installIntent.SetData(installerUri); - } - + Intent installIntent = this.CreateInstallIntent(installerUri); installIntent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.NewTask); installIntent.PutExtra(Intent.ExtraReturnResult, false); - if (installIntent.ResolveActivity(context.PackageManager) == null) + if (installIntent.ResolveActivity(context.PackageManager) is null) { throw new ApplicationException("No installer is available on this device to open the update package."); } @@ -117,6 +92,47 @@ private async Task LaunchAndroidUpdateAsync(Uri updateUri, } } + private void ValidateAndroidUpdateUri(Uri updateUri) + { + if (updateUri.Scheme != Uri.UriSchemeHttp && updateUri.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException("Android updates require a valid HTTP or HTTPS download address.", nameof(updateUri)); + } + } + + private void EnsureInstallPermission(Context context) + { + if (!OperatingSystem.IsAndroidVersionAtLeast(26) || context.PackageManager?.CanRequestPackageInstalls() is true) + { + return; + } + + Intent settingsIntent = new(Settings.ActionManageUnknownAppSources); + settingsIntent.SetData(Android.Net.Uri.Parse($"package:{context.PackageName}")); + settingsIntent.AddFlags(ActivityFlags.NewTask); + context.StartActivity(settingsIntent); + + throw new ApplicationException("Allow installs from this app, then retry the update."); + } + + private Intent CreateInstallIntent(Android.Net.Uri installerUri) + { + Intent installIntent = OperatingSystem.IsAndroidVersionAtLeast(29) + ? new Intent(Intent.ActionView) + : new Intent(Intent.ActionInstallPackage); + + if (OperatingSystem.IsAndroidVersionAtLeast(29)) + { + installIntent.SetDataAndType(installerUri, AndroidPackageArchiveMimeType); + } + else + { + installIntent.SetData(installerUri); + } + + return installIntent; + } + private async Task DownloadUpdatePackageAsync(Uri updateUri, CancellationToken cancellationToken) { @@ -134,7 +150,7 @@ private async Task DownloadUpdatePackageAsync(Uri updateUri, HttpClient httpClient = this.HttpClientFactory.CreateClient("default"); using HttpResponseMessage response = await httpClient.GetAsync(updateUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - if (response.IsSuccessStatusCode == false) + if (!response.IsSuccessStatusCode) { throw new ApplicationException($"The update package could not be downloaded ({(Int32)response.StatusCode} {response.ReasonPhrase})."); } @@ -160,7 +176,7 @@ private String GetUpdateFileName(Uri updateUri) fileName = fileName.Replace(invalidCharacter, '_'); } - if (fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) == false) + if (!fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)) { fileName = $"{fileName}.apk"; } From 502f07e32f2c4f137add9a7f371e27c46006c534 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:53:22 +0000 Subject: [PATCH 12/14] chore: document update helpers Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/2e70d176-355f-44ac-9b7f-744249fd14f2 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ViewModels/LoginPageViewModel.cs | 16 ++++++++++++++++ .../ApplicationUpdateLauncherService.cs | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs index 5c756ef01..b1ddf0686 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs @@ -188,8 +188,15 @@ private async Task CheckForUpdates(Configuration configuration) { await this.LaunchRequiredUpdate(downloadUri); } + /// + /// Determines whether automatic update checks should run for the current configuration. + /// private Boolean ShouldCheckForUpdates(Configuration configuration) => configuration?.EnableAutoUpdates is true; + /// + /// Evaluates the update check result and returns whether a mandatory update is required. + /// Logs a warning when the update check fails. + /// private Boolean IsUpdateRequired(Result updateCheckResult) { if (updateCheckResult.IsFailed) { @@ -200,11 +207,17 @@ private Boolean IsUpdateRequired(Result updateCh return updateCheckResult.Data.UpdateRequired; } + /// + /// Builds the user-facing mandatory update message, preferring the server-provided value. + /// private String BuildUpdateMessage(ApplicationUpdateCheckResponse updateResponse) => String.IsNullOrWhiteSpace(updateResponse.Message) ? $"Version {updateResponse.LatestVersion ?? "latest"} is available and must be installed before you can continue. The installer will open and the app will close." : updateResponse.Message; + /// + /// Returns the configured download URI for a mandatory update or throws when none is available. + /// private String GetRequiredDownloadUri(ApplicationUpdateCheckResponse updateResponse) { if (String.IsNullOrWhiteSpace(updateResponse.DownloadUri)) { @@ -214,6 +227,9 @@ private String GetRequiredDownloadUri(ApplicationUpdateCheckResponse updateRespo return updateResponse.DownloadUri; } + /// + /// Starts the mandatory update flow, notifies the user, and closes the app after launching the installer. + /// private async Task LaunchRequiredUpdate(String downloadUri) { this.IsBusy = true; diff --git a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs index 7460cddde..e4de19c2c 100644 --- a/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -92,6 +92,9 @@ private async Task LaunchAndroidUpdateAsync(Uri updateUri, } } + /// + /// Validates that Android update downloads use an HTTP or HTTPS URI. + /// private void ValidateAndroidUpdateUri(Uri updateUri) { if (updateUri.Scheme != Uri.UriSchemeHttp && updateUri.Scheme != Uri.UriSchemeHttps) @@ -100,6 +103,10 @@ private void ValidateAndroidUpdateUri(Uri updateUri) } } + /// + /// Ensures Android 8.0+ install permissions are enabled before attempting to launch the installer. + /// Opens system settings and throws when the permission is not currently granted. + /// private void EnsureInstallPermission(Context context) { if (!OperatingSystem.IsAndroidVersionAtLeast(26) || context.PackageManager?.CanRequestPackageInstalls() is true) @@ -115,6 +122,9 @@ private void EnsureInstallPermission(Context context) throw new ApplicationException("Allow installs from this app, then retry the update."); } + /// + /// Creates the platform installer intent, using Android 10+ view semantics and legacy install-package semantics on older versions. + /// private Intent CreateInstallIntent(Android.Net.Uri installerUri) { Intent installIntent = OperatingSystem.IsAndroidVersionAtLeast(29) From db278c28040ec07fde28aad91048d8a47a569c59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:04:47 +0000 Subject: [PATCH 13/14] fix: stop login after mandatory update launch Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/9a33b0c5-a0b5-435f-a74f-c3f21b493ed6 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ViewModels/LoginPageViewModel.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs index b1ddf0686..7e2473b07 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs @@ -160,9 +160,9 @@ private async Task> GetMerchantBalance() { return getMerchantBalanceResult; } - private async Task CheckForUpdates(Configuration configuration) { + private async Task CheckForUpdates(Configuration configuration) { if (!this.ShouldCheckForUpdates(configuration)) { - return; + return true; } Result updateCheckResult = await this.UpdateService.CheckForUpdates(this.ApplicationInfoService.VersionString, @@ -172,7 +172,7 @@ private async Task CheckForUpdates(Configuration configuration) { CancellationToken.None); if (!this.IsUpdateRequired(updateCheckResult)) { - return; + return true; } ApplicationUpdateCheckResponse updateResponse = updateCheckResult.Data; @@ -186,6 +186,7 @@ private async Task CheckForUpdates(Configuration configuration) { String downloadUri = this.GetRequiredDownloadUri(updateResponse); await this.LaunchRequiredUpdate(downloadUri); + return false; } /// @@ -259,7 +260,9 @@ private async Task Logon(){ Result configurationResult = await this.GetConfiguration(); this.HandleResult(configurationResult); - await this.CheckForUpdates(configurationResult.Data); + if (!await this.CheckForUpdates(configurationResult.Data)) { + return; + } await this.WriteTimingTrace(sw, "After GetConfiguration"); Result getTokenResult = await this.GetUserToken(); From ae1ead54e969acdbda184af66ab502aa7ce26460 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:08:18 +0000 Subject: [PATCH 14/14] style: use bool for update flow guard Agent-Logs-Url: https://github.com/TransactionProcessing/TransactionMobile/sessions/9a33b0c5-a0b5-435f-a74f-c3f21b493ed6 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ViewModels/LoginPageViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs index 7e2473b07..fe4f1ad36 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/LoginPageViewModel.cs @@ -160,7 +160,7 @@ private async Task> GetMerchantBalance() { return getMerchantBalanceResult; } - private async Task CheckForUpdates(Configuration configuration) { + private async Task CheckForUpdates(Configuration configuration) { if (!this.ShouldCheckForUpdates(configuration)) { return true; }