Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Microsoft.Maui-mac.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"src\\Essentials\\samples\\Samples\\Essentials.Sample.csproj",
"src\\Essentials\\src\\Essentials.csproj",
"src\\Essentials\\test\\DeviceTests\\Essentials.DeviceTests.csproj",
"src\\Essentials\\test\\ExternalBackendTests\\Essentials.ExternalBackend.UnitTests.csproj",
"src\\Essentials\\test\\UnitTests\\Essentials.UnitTests.csproj",
"src\\Graphics\\src\\Graphics.Skia\\Graphics.Skia.csproj",
"src\\Graphics\\src\\Graphics\\Graphics.csproj",
Expand Down
1 change: 1 addition & 0 deletions Microsoft.Maui-windows.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"src\\Essentials\\samples\\Samples\\Essentials.Sample.csproj",
"src\\Essentials\\src\\Essentials.csproj",
"src\\Essentials\\test\\DeviceTests\\Essentials.DeviceTests.csproj",
"src\\Essentials\\test\\ExternalBackendTests\\Essentials.ExternalBackend.UnitTests.csproj",
"src\\Essentials\\test\\UnitTests\\Essentials.UnitTests.csproj",
"src\\Graphics\\samples\\GraphicsTester.Android\\GraphicsTester.Android.csproj",
"src\\Graphics\\samples\\GraphicsTester.Portable\\GraphicsTester.Portable.csproj",
Expand Down
7 changes: 7 additions & 0 deletions Microsoft.Maui.sln
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.DeviceTests", "s
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.UnitTests", "src\Essentials\test\UnitTests\Essentials.UnitTests.csproj", "{A9BAB840-A53E-405A-9458-CA0E02319FB4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Essentials.ExternalBackend.UnitTests", "src\Essentials\test\ExternalBackendTests\Essentials.ExternalBackend.UnitTests.csproj", "{7C6A0D6E-9B4E-4C0E-9E3C-2F1A5D8B4E71}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Controls.Maps", "src\Controls\Maps\src\Controls.Maps.csproj", "{5C40FA00-7D48-4F54-898C-01C8942E451D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{25D0D27A-C5FE-443D-8B65-D6C987F4A80E}"
Expand Down Expand Up @@ -368,6 +370,10 @@ Global
{A9BAB840-A53E-405A-9458-CA0E02319FB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9BAB840-A53E-405A-9458-CA0E02319FB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9BAB840-A53E-405A-9458-CA0E02319FB4}.Release|Any CPU.Build.0 = Release|Any CPU
{7C6A0D6E-9B4E-4C0E-9E3C-2F1A5D8B4E71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C6A0D6E-9B4E-4C0E-9E3C-2F1A5D8B4E71}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C6A0D6E-9B4E-4C0E-9E3C-2F1A5D8B4E71}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C6A0D6E-9B4E-4C0E-9E3C-2F1A5D8B4E71}.Release|Any CPU.Build.0 = Release|Any CPU
{5C40FA00-7D48-4F54-898C-01C8942E451D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5C40FA00-7D48-4F54-898C-01C8942E451D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5C40FA00-7D48-4F54-898C-01C8942E451D}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down Expand Up @@ -686,6 +692,7 @@ Global
{420BC8CC-EE70-49A1-964A-CB63414A882D} = {94F3C036-A5F4-4ACC-A028-8506802ADB88}
{66528BE6-FF53-4A3B-8675-46C0B929C83B} = {420BC8CC-EE70-49A1-964A-CB63414A882D}
{A9BAB840-A53E-405A-9458-CA0E02319FB4} = {420BC8CC-EE70-49A1-964A-CB63414A882D}
{7C6A0D6E-9B4E-4C0E-9E3C-2F1A5D8B4E71} = {420BC8CC-EE70-49A1-964A-CB63414A882D}
{5C40FA00-7D48-4F54-898C-01C8942E451D} = {50C758FE-4E10-409A-94F5-A75480960864}
{25D0D27A-C5FE-443D-8B65-D6C987F4A80E} = {459BF674-83CB-46F6-881F-A2D2117DBF4D}
{094FB109-1CBC-4B84-8CFB-072E7B3EC637} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
Expand Down
1 change: 1 addition & 0 deletions eng/cake/dotnet.cake
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ Task("dotnet-test")
"**/Controls.BindingSourceGen.UnitTests.csproj",
"**/Core.UnitTests.csproj",
"**/Essentials.UnitTests.csproj",
"**/Essentials.ExternalBackend.UnitTests.csproj",
"**/Resizetizer.UnitTests.csproj",
"**/Graphics.Tests.csproj",
"**/MauiBlazorWebView.UnitTests.csproj",
Expand Down
1 change: 1 addition & 0 deletions eng/helix.proj
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<XUnitProject Include="$(RepoRoot)src/Controls/tests/SourceGen.UnitTests/SourceGen.UnitTests.csproj" />
<XUnitProject Include="$(RepoRoot)src/Core/tests/UnitTests/Core.UnitTests.csproj" />
<XUnitProject Include="$(RepoRoot)src/Essentials/test/UnitTests/Essentials.UnitTests.csproj" />
<XUnitProject Include="$(RepoRoot)src/Essentials/test/ExternalBackendTests/Essentials.ExternalBackend.UnitTests.csproj" />
<XUnitProject Include="$(RepoRoot)src/Graphics/tests/Graphics.Tests/Graphics.Tests.csproj" />
<XUnitProject Include="$(RepoRoot)src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/MauiBlazorWebView.UnitTests.csproj" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authent
static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.PasskeyCreationResponse!>!
static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
Microsoft.Maui.Media.Locale.Locale(string? language, string? country, string? name, string? id) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authent
static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.PasskeyCreationResponse!>!
static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
Microsoft.Maui.Media.Locale.Locale(string? language, string? country, string? name, string? id) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authent
static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.PasskeyCreationResponse!>!
static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
Microsoft.Maui.Media.Locale.Locale(string? language, string? country, string? name, string? id) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authent
static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.PasskeyCreationResponse!>!
static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
Microsoft.Maui.Media.Locale.Locale(string? language, string? country, string? name, string? id) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authent
static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.PasskeyCreationResponse!>!
static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
Microsoft.Maui.Media.Locale.Locale(string? language, string? country, string? name, string? id) -> void
1 change: 1 addition & 0 deletions src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authent
static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.PasskeyCreationResponse!>!
static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
Microsoft.Maui.Media.Locale.Locale(string? language, string? country, string? name, string? id) -> void
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ static Microsoft.Maui.Authentication.Passkeys.CreateAsync(Microsoft.Maui.Authent
static Microsoft.Maui.Authentication.Passkeys.CreateAsync(string! creationOptionsJson, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.PasskeyCreationResponse!>!
static Microsoft.Maui.Authentication.Passkeys.Default.get -> Microsoft.Maui.Authentication.IPasskeys!
static Microsoft.Maui.Authentication.Passkeys.IsSupported.get -> bool
Microsoft.Maui.Media.Locale.Locale(string? language, string? country, string? name, string? id) -> void
4 changes: 2 additions & 2 deletions src/Essentials/src/TextToSpeech/TextToSpeech.android.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ public async Task SpeakAsync(string text, int max, SpeechOptions options, Cancel
});
}

if (options?.Locale?.Language != null)
if (!string.IsNullOrEmpty(options?.Locale?.Language))
{
JavaLocale locale = null;
if (!string.IsNullOrWhiteSpace(options?.Locale.Country))
if (!string.IsNullOrWhiteSpace(options.Locale.Country))
locale = new JavaLocale(options.Locale.Language, options.Locale.Country);
else
locale = new JavaLocale(options.Locale.Language);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ static AVSpeechUtterance GetSpeechUtterance(string text, SpeechOptions options)
// null voice if fine - it is the default
// select the voice by identifier else by Language, otherwise set for default
speechUtterance.Voice =
options.Locale?.Id != null
!string.IsNullOrEmpty(options.Locale?.Id)
? AVSpeechSynthesisVoice.FromIdentifier(options.Locale.Id)
: AVSpeechSynthesisVoice.FromLanguage(options.Locale?.Language)
?? AVSpeechSynthesisVoice.FromLanguage(AVSpeechSynthesisVoice.CurrentLanguageCode);
Expand Down
2 changes: 1 addition & 1 deletion src/Essentials/src/TextToSpeech/TextToSpeech.macos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async Task PlatformSpeakAsync(string text, SpeechOptions options, CancellationTo
if (options.Volume.HasValue)
ss.Volume = NormalizeVolume(options.Volume);

if (options.Locale != null)
if (!string.IsNullOrEmpty(options.Locale?.Id))
ss.Voice = options.Locale.Id;

if (options.Rate.HasValue)
Expand Down
50 changes: 42 additions & 8 deletions src/Essentials/src/TextToSpeech/TextToSpeech.shared.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,31 @@ public async Task SpeakAsync(string text, SpeechOptions? options = default, Canc
/// <summary>
/// Represents a specific geographical, political, or cultural region.
/// </summary>
/// <remarks>
/// <para>
/// Instances are normally obtained from <see cref="ITextToSpeech.GetLocalesAsync"/>. The constructor is
/// public so that text-to-speech backends which live outside of .NET MAUI can implement
/// <see cref="ITextToSpeech"/> and return values that .NET MAUI understands.
/// </para>
/// <para>
/// The exact meaning of <see cref="Language"/>, <see cref="Country"/>, <see cref="Name"/> and <see cref="Id"/>
/// is defined by the text-to-speech engine that produced the locale, so values are stored verbatim: they are
/// not parsed, validated, trimmed, or case normalized. The only normalization performed is that
/// <see langword="null"/> is converted to <see cref="string.Empty"/>, so every property is guaranteed to be
/// non-<see langword="null"/>.
/// </para>
/// <para>
/// Locales are immutable and use reference equality; two instances created with identical values are not
/// considered equal.
/// </para>
/// </remarks>
public class Locale
{
/// <summary>
/// Gets the language name or code.
/// </summary>
/// <remarks>
/// <para>This value may vary between platforms.</para>
/// <para>This value may vary between platforms and is never <see langword="null"/>; an unknown language is represented by <see cref="string.Empty"/>.</para>
/// <para>
/// For Android this used the ISO 639 alpha-2 or alpha-3 language code, or registered language subtags up to 8 alpha letters (for future enhancements).
/// When a language has both an alpha-2 code and an alpha-3 code, the alpha-2 code must be used.
Expand All @@ -264,28 +282,44 @@ public class Locale
/// Gets the country name or code.
/// </summary>
/// <remarks>
/// <para>This value may vary between platforms.</para>
/// <para>This value may vary between platforms and is never <see langword="null"/>; an unknown or unused country is represented by <see cref="string.Empty"/>.</para>
/// <para>For Android this used the ISO 3166 alpha-2 country code or UN M.49 numeric-3 area code.</para>
/// <para>For iOS and Windows this field is not used and <see langword="null"/> .</para>
/// <para>For iOS and Windows this field is not used and is <see cref="string.Empty"/>. On those platforms the region is already part of <see cref="Language"/> (for example <c>en-US</c>).</para>
/// </remarks>
public string Country { get; }

/// <summary>
/// Gets the display name of the locale.
/// </summary>
/// <remarks>This value is never <see langword="null"/>; engines that do not provide a display name report <see cref="string.Empty"/>.</remarks>
public string Name { get; }

/// <summary>
/// Gets the unique identifier of the locale.
/// </summary>
/// <remarks>
/// <para>This identifier is only meaningful to the text-to-speech engine that produced it and is never <see langword="null"/>; engines that do not expose an identifier report <see cref="string.Empty"/>.</para>
/// <para>When this value is empty, platforms fall back to selecting a voice using <see cref="Language"/> and <see cref="Country"/>.</para>
/// </remarks>
public string Id { get; }

internal Locale(string language, string country, string name, string id)
/// <summary>
/// Initializes a new instance of the <see cref="Locale"/> class.
/// </summary>
/// <param name="language">The language name or code, as described by <see cref="Language"/>.</param>
/// <param name="country">The country name or code, as described by <see cref="Country"/>. Pass <see langword="null"/> when the language already carries the region, or when the engine does not report one.</param>
/// <param name="name">The display name of the locale, as described by <see cref="Name"/>.</param>
/// <param name="id">The engine specific identifier of the locale, as described by <see cref="Id"/>.</param>
/// <remarks>
/// No argument is required and none are validated. A <see langword="null"/> argument is normalized to
/// <see cref="string.Empty"/>; every other value is stored exactly as supplied.
/// </remarks>
public Locale(string? language, string? country, string? name, string? id)
{
Language = language;
Country = country;
Name = name;
Id = id;
Language = language ?? string.Empty;
Country = country ?? string.Empty;
Name = name ?? string.Empty;
Id = id ?? string.Empty;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Essentials/src/TextToSpeech/TextToSpeech.tizen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ async Task PlatformSpeakAsync(string text, SpeechOptions options, CancellationTo

var language = "en_US";
var voiceType = Voice.Auto;
if (options?.Locale.Language != null)
if (!string.IsNullOrEmpty(options?.Locale?.Language))
{
foreach (var voice in tts.GetSupportedVoices())
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
This project intentionally does NOT have InternalsVisibleTo access to
Microsoft.Maui.Essentials. It exists to prove that an out-of-tree platform
backend (for example a community Tizen backend) can implement ITextToSpeech
using only the public Essentials API surface.
-->
<PropertyGroup>
<TargetFramework>$(_MauiDotNetTfm)</TargetFramework>
<IsPackable>false</IsPackable>
<AssemblyName>Microsoft.Maui.Essentials.ExternalBackend.UnitTests</AssemblyName>
<Configurations>Debug;Release</Configurations>
<Nullable>enable</Nullable>
</PropertyGroup>

<PropertyGroup Condition=" '$(Configuration)'=='Debug' ">
<DebugSymbols>true</DebugSymbols>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="xunit" Version="$(XunitPackageVersion)" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Essentials.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Maui.Media;
using Xunit;

namespace Microsoft.Maui.Essentials.ExternalBackend.UnitTests
{
/// <summary>
/// Stands in for an out-of-tree platform backend (for example a community Tizen
/// backend). It is compiled into an assembly that has no <c>InternalsVisibleTo</c>
/// access to Microsoft.Maui.Essentials, so it can only use the public API surface.
/// </summary>
sealed class ExternalTextToSpeech : ITextToSpeech
{
public SpeechOptions? LastOptions { get; private set; }

public Task<IEnumerable<Locale>> GetLocalesAsync() =>
Task.FromResult<IEnumerable<Locale>>(new[]
{
new Locale("en", "US", "English (United States)", "external:en-US"),
new Locale("ko", "KR", "Korean (Korea)", "external:ko-KR"),

// A backend that can only report a language uses null for the rest.
new Locale("fr", null, null, null),
});

public Task SpeakAsync(string text, SpeechOptions? options = default, CancellationToken cancelToken = default)
{
LastOptions = options;
return Task.CompletedTask;
}
}

public class ExternalTextToSpeechBackendTests
{
[Fact]
public void LocaleConstructorIsAccessibleOutsideEssentials()
{
var locale = new Locale("en", "US", "English (United States)", "external:en-US");

Assert.Equal("en", locale.Language);
Assert.Equal("US", locale.Country);
Assert.Equal("English (United States)", locale.Name);
Assert.Equal("external:en-US", locale.Id);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void NullAndEmptyValuesNormalizeToEmptyString(string? value)
{
var locale = new Locale(value!, value, value, value);
Comment thread
Copilot marked this conversation as resolved.
Outdated

Assert.Equal(string.Empty, locale.Language);
Assert.Equal(string.Empty, locale.Country);
Assert.Equal(string.Empty, locale.Name);
Assert.Equal(string.Empty, locale.Id);
}

[Fact]
public void ValuesAreNotTrimmedOrCaseNormalized()
{
var locale = new Locale("EN", "us", " Display ", "Id");

Assert.Equal("EN", locale.Language);
Assert.Equal("us", locale.Country);
Assert.Equal(" Display ", locale.Name);
Assert.Equal("Id", locale.Id);
}

[Fact]
public async Task ExternalBackendCanReturnLocalesFromGetLocalesAsync()
{
ITextToSpeech tts = new ExternalTextToSpeech();

var locales = (await tts.GetLocalesAsync()).ToList();

Assert.Equal(3, locales.Count);
Assert.Contains(locales, l => l.Language == "en" && l.Country == "US");
Assert.Contains(locales, l => l.Language == "ko" && l.Id == "external:ko-KR");

// Language-only locales still expose non-null property values.
var languageOnly = Assert.Single(locales, l => l.Language == "fr");
Assert.Equal(string.Empty, languageOnly.Country);
Assert.Equal(string.Empty, languageOnly.Name);
Assert.Equal(string.Empty, languageOnly.Id);
}

[Fact]
public async Task ExternalBackendCanRoundTripLocaleThroughSpeechOptions()
{
var backend = new ExternalTextToSpeech();
ITextToSpeech tts = backend;

var locale = (await tts.GetLocalesAsync()).First();

await tts.SpeakAsync("hello", new SpeechOptions { Locale = locale, Rate = 1.0f });

Assert.NotNull(backend.LastOptions);
Assert.Same(locale, backend.LastOptions!.Locale);
Assert.Equal("en", backend.LastOptions.Locale!.Language);
}

[Fact]
public void LocaleUsesReferenceEqualityAndDefaultToString()
{
var first = new Locale("en", "US", "English (United States)", "external:en-US");
var second = new Locale("en", "US", "English (United States)", "external:en-US");

Assert.NotEqual(first, second);
Assert.Equal(first, first);
Assert.Equal(typeof(Locale).FullName, first.ToString());
}
}
}
Loading