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..fb2240914 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,101 @@ public void LoginPageViewModel_LoginCommand_Execute_ErrorGettingToken_WarningToa CancellationToken.None), Times.Once); } + [Fact] + 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 })); + 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")); + + await this.ViewModel.LogonCommand.ExecuteAsync(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 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 })); + 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); + + await this.ViewModel.LogonCommand.ExecuteAsync(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); + this.NavigationService.Verify(n => n.GoToHome(), Times.Never); + this.DialogService.Verify(n => n.ShowWarningToast(It.IsAny(), + null, + "OK", + null, + CancellationToken.None), Times.Never); + } + + [Fact] + 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 })); + 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.")); + + await this.ViewModel.LogonCommand.ExecuteAsync(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() { @@ -196,4 +298,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..0f2db93dd --- /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.Logging; +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 sealed record ApplicationUpdateCheckRequest(String ApplicationVersion, + String PackageName, + String Platform, + String DeviceIdentifier); + + 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"); + ApplicationUpdateCheckRequest request = new(applicationVersion, packageName, platform, deviceIdentifier); + + try + { + Logger.LogInformation($"About to check for application updates for device identifier {deviceIdentifier}"); + Logger.LogDebug($"Application update request details: Uri {requestUri}"); + + 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); + 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..fe4f1ad36 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 @@ -77,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); } @@ -154,6 +160,93 @@ private async Task> GetMerchantBalance() { return getMerchantBalanceResult; } + private async Task CheckForUpdates(Configuration configuration) { + if (!this.ShouldCheckForUpdates(configuration)) { + return true; + } + + Result updateCheckResult = await this.UpdateService.CheckForUpdates(this.ApplicationInfoService.VersionString, + this.ApplicationInfoService.PackageName, + this.DeviceService.GetPlatform(), + this.DeviceService.GetIdentifier(), + CancellationToken.None); + + if (!this.IsUpdateRequired(updateCheckResult)) { + return true; + } + + 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 downloadUri = this.GetRequiredDownloadUri(updateResponse); + + await this.LaunchRequiredUpdate(downloadUri); + return false; + } + + /// + /// 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) { + Logger.LogWarning($"Application update check failed: {updateCheckResult.Message}"); + return false; + } + + 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)) { + throw new ApplicationException("An application update is required, but no download location is configured."); + } + + 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; + + try + { + await this.DialogService.ShowInformationToast("Downloading the required update..."); + await this.ApplicationUpdateLauncherService.LaunchUpdateAsync(downloadUri, CancellationToken.None); + await this.NavigationService.QuitApplication(); + } + finally + { + this.IsBusy = false; + } + } + [RelayCommand] private async Task Logon(){ CorrelationIdProvider.NewId(); @@ -167,6 +260,9 @@ private async Task Logon(){ Result configurationResult = await this.GetConfiguration(); this.HandleResult(configurationResult); + if (!await this.CheckForUpdates(configurationResult.Data)) { + return; + } await this.WriteTimingTrace(sw, "After GetConfiguration"); Result getTokenResult = await this.GetUserToken(); @@ -241,4 +337,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 0dc5bfe36..9b51cd1d3 100644 --- a/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs +++ b/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs @@ -90,11 +90,21 @@ 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 +132,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 +222,7 @@ public static MauiAppBuilder ConfigureUIServices(this MauiAppBuilder builder) { builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); return builder; 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/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 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..e4de19c2c --- /dev/null +++ b/TransactionProcessor.Mobile/UIServices/ApplicationUpdateLauncherService.cs @@ -0,0 +1,197 @@ +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.Provider; +using AndroidX.Core.Content; +using JavaFile = Java.IO.File; +using MauiFileProvider = AndroidX.Core.Content.FileProvider; +#endif + +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) + { + this.HttpClientFactory = httpClientFactory; + } + + public async Task LaunchUpdateAsync(String downloadUri, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(downloadUri); + + 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)); + } + +#if ANDROID + if (OperatingSystem.IsAndroid()) + { + await this.LaunchAndroidUpdateAsync(updateUri, cancellationToken); + return; + } +#endif + + if (updateUri.Scheme == Uri.UriSchemeHttp || updateUri.Scheme == Uri.UriSchemeHttps) + { + await Browser.Default.OpenAsync(updateUri, BrowserLaunchMode.External); + return; + } + + if (!await Launcher.Default.CanOpenAsync(updateUri)) + { + throw new InvalidOperationException($"The application update address '{downloadUri}' cannot be opened on this device."); + } + + await Launcher.Default.OpenAsync(updateUri); + } + +#if ANDROID + private async Task LaunchAndroidUpdateAsync(Uri updateUri, + CancellationToken cancellationToken) + { + this.ValidateAndroidUpdateUri(updateUri); + + try + { + Context context = Platform.AppContext ?? Android.App.Application.Context; + this.EnsureInstallPermission(context); + + String updateFilePath = await this.DownloadUpdatePackageAsync(updateUri, cancellationToken); + JavaFile updateFile = new(updateFilePath); + + 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 = this.CreateInstallIntent(installerUri); + installIntent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.NewTask); + installIntent.PutExtra(Intent.ExtraReturnResult, false); + + if (installIntent.ResolveActivity(context.PackageManager) is null) + { + throw new ApplicationException("No installer is available on this device to open the update package."); + } + + context.StartActivity(installIntent); + } + 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); + } + } + + /// + /// 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) + { + throw new ArgumentException("Android updates require a valid HTTP or HTTPS download address.", nameof(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) + { + 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."); + } + + /// + /// 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) + ? 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) + { + 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) + { + 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, DownloadBufferSize, 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)) + { + fileName = $"{fileName}.apk"; + } + + return fileName; + } +#endif +}