Skip to content

[Essentials] Make Locale constructible by external TextToSpeech backends - #37857

Open
Redth wants to merge 4 commits into
net11.0from
redth-essentials-locale-public-construction
Open

[Essentials] Make Locale constructible by external TextToSpeech backends#37857
Redth wants to merge 4 commits into
net11.0from
redth-essentials-locale-public-construction

Conversation

@Redth

@Redth Redth commented Aug 26, 2026

Copy link
Copy Markdown
Member

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description of Change

Microsoft.Maui.Media.Locale only had an internal constructor. That makes it impossible for a text-to-speech backend that lives outside of this repo to implement ITextToSpeech: it cannot return anything from GetLocalesAsync(), and it cannot construct a Locale to hand back through SpeechOptions.Locale.

This is a concrete blocker today — Redth/Maui.Tizen#8 has to mark locale enumeration as unsupported (GetLocalesAsync returns an empty list) purely because of this, even though the Tizen TtsClient.GetSupportedVoices() API returns everything needed.

Reproduction from an assembly that does not have InternalsVisibleTo access to Microsoft.Maui.Essentials:

error CS1729: 'Locale' does not contain a constructor that takes 4 arguments

What changed

  1. Locale gains a public constructor (public Locale(string? language, string? country, string? name, string? id)). This is the smallest additive change that unblocks the scenario — no builder, no factory, no new type.

  2. null is normalized to string.Empty. PublicAPI.Shipped.txt already declares all four properties as non-nullable (string!), but the in-box platform backends pass null for Country/Name/Id. Now that arbitrary callers can construct a Locale, the declared contract is actually enforced, so consumers can rely on the properties never being null. Values are otherwise stored verbatim — they are not parsed, validated, trimmed, or case normalized, because their meaning is defined by whichever engine produced them.

  3. Immutability, reference equality, and ToString() are unchanged. No Equals/GetHashCode/ToString overrides were added, so existing behavior is preserved exactly; there is a test that pins this.

  4. Platform voice-selection guards tightened. Because a caller can now build a locale that only carries a Language (or only an Id), the != null checks in the platform implementations were changed to string.IsNullOrEmpty so that a partially-populated locale falls back the same way an absent one does today. Platform-produced locales always populated these fields, so in-box behavior is unchanged.

    • This also fixes a latent NullReferenceException on Tizen, where options?.Locale.Language dereferenced a null Locale.
  5. Culture semantics documented. The XML docs now state that Language is BCP-47 on iOS/Windows and ISO 639 on Android, that Country is ISO 3166 / UN M.49 on Android and unused (empty) on iOS/Windows because the region is already carried in Language, that Id is engine-specific and that platforms fall back to Language/Country when it is empty, and that null normalizes to string.Empty.

Issues Fixed

Unblocks out-of-tree ITextToSpeech backends, e.g. Redth/Maui.Tizen#8.

Testing

Added src/Essentials/test/ExternalBackendTests (Microsoft.Maui.Essentials.ExternalBackend.UnitTests). This assembly deliberately has no InternalsVisibleTo grant from Microsoft.Maui.Essentials, so it can only use the public surface — it is the regression guard for exactly the scenario this PR unblocks. It contains a stand-in external backend that implements ITextToSpeech, and covers:

  • the constructor being reachable from outside Essentials;
  • null and "" normalizing to string.Empty;
  • values not being trimmed or case normalized;
  • an external backend returning Locales from GetLocalesAsync(), including a language-only locale;
  • round-tripping a Locale through SpeechOptions into SpeakAsync.

The project is registered in eng/helix.proj, eng/cake/dotnet.cake, Microsoft.Maui.sln, and both solution filters, so it runs alongside the other unit test assemblies.

Verified locally on macOS:

  • Essentials.ExternalBackend.UnitTests — 6/6 passed (and confirmed to fail to compile with CS1729 before the change).
  • Essentials.UnitTests — 513/513 passed.
  • Essentials.csproj builds clean (including the PublicAPI analyzers) for net11.0, netstandard2.0, netstandard2.1, net11.0-ios, net11.0-maccatalyst, and net11.0-android.

PublicAPI.Unshipped.txt entries were added for all seven TFM folders (net, net-android, net-ios, net-maccatalyst, net-tizen, net-windows, netstandard).

Reviewer notes

The nullstring.Empty normalization is an observable change. PublicAPI.Shipped.txt already declared all four properties as non-nullable string!, but the in-box backends passed literal null for Country/Name/Id, so the runtime value contradicted the annotation. This change aligns runtime with the declared contract. External consumers who defensively null-check Locale.Country will now see "" instead of null.

Platform voice-selection guards were audited for the newly-reachable inputs. Because a caller can now construct a Locale carrying only some fields, every platform guard was reviewed:

  • WindowsGetSpeakParametersSSMLProsody resolved the language with options?.Locale?.Language ?? SpeechSynthesizer.DefaultVoice.Language. Normalization defeats that ?? ("" is not null), so the default-voice fallback stopped firing and the SSML was emitted as xml:lang=''. Fixed with an IsNullOrWhiteSpace check. Verified against a standalone harness: the previous expression emits xml:lang='' for new Locale(null, …) and new Locale("", …), while the new one falls back to DefaultVoice.Language for null/empty/whitespace and preserves a real language unchanged.
  • iOS — an unrecognized Id makes AVSpeechSynthesisVoice.FromIdentifier return null; sitting in the true branch of a ternary with no coalesce, that skipped Language entirely and set Voice = null. Now a single coalescing chain: identifier → language → current language, matching the intent the existing comment already described. (The original ?:/?? precedence was correct; this is a separate fall-through gap.)
  • Android / Tizen — guards switched to IsNullOrWhiteSpace. The Tizen change also fixes a latent NullReferenceException: options?.Locale.Language threw whenever options != null && options.Locale == null.
  • macOS.macos.cs is not compiled for any shipping TFM (its ItemGroup in Essentials.csproj is commented out and there is no net-macos PublicAPI folder), so it is neither type-checked nor CI-verified. Its guard was kept faithful to the original reset semantics: assigning null returns NSSpeechSynthesizer to the system default, and since the synthesizer is a cached instance, skipping the assignment would let a previous utterance's voice leak into a call that asked for no specific voice.

Known coverage gap. GetSpeakParametersSSMLProsody is static and trivially unit-testable, but it compiles only for the Windows TFM, while Essentials.UnitTests targets $(_MauiDotNetTfm) (net11.0). There is therefore no in-repo home for a regression test on the Windows fix without adding a Windows-targeted unit test project. Flagging rather than silently skipping; happy to add that project if reviewers want it.

Equality/ToString() are deliberately left unspecified. An earlier revision asserted reference equality and the compiler-default ToString(). Those assertions were removed, along with the matching sentence in the XML docs, so that a future conversion of Locale to a record or an IEquatable<Locale> implementation is not pre-emptively blocked.

Microsoft.Maui.Media.Locale only had an internal constructor, so an
out-of-tree ITextToSpeech implementation could not implement
GetLocalesAsync or populate SpeechOptions.Locale without reflection or
forking. Community backends (for example Maui.Tizen) had to mark locale
enumeration as unsupported.

Make the existing constructor public and normalize null arguments to
string.Empty so the non-nullable Language/Country/Name/Id contract that
PublicAPI already declares is actually honored. Values are otherwise
stored verbatim: not parsed, validated, trimmed, or case normalized.
Immutability, reference equality, and the default ToString are unchanged.

Because locales can now be created with empty Id/Language, tighten the
platform voice-selection guards from `!= null` to string.IsNullOrEmpty so
a language-only locale still falls back the way it does today. This also
fixes a latent NullReferenceException on Tizen where `options?.Locale.Language`
dereferenced a null Locale.

Add Essentials.ExternalBackend.UnitTests, a test assembly that has no
InternalsVisibleTo access to Microsoft.Maui.Essentials, proving an
external backend can implement ITextToSpeech, return Locale values from
GetLocalesAsync, and round-trip them through SpeechOptions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 26, 2026 21:25
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 21:25 — with GitHub Actions Inactive
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 37857

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 37857"

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 21:25 — with GitHub Actions Inactive
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 21:27 — with GitHub Actions Inactive
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 21:29 — with GitHub Actions Inactive
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 21:30 — with GitHub Actions Inactive
@github-actions github-actions Bot added area-essentials Essentials: Device, Display, Connectivity, Secure Storage, Sensors, App Info platform/android labels Aug 26, 2026
@Redth
Redth temporarily deployed to copilot-pat-pool August 26, 2026 21:30 — with GitHub Actions Inactive

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Enables out-of-tree ITextToSpeech backends to construct and return Microsoft.Maui.Media.Locale instances by making Locale publicly constructible, normalizing null inputs, and adding a regression test project that runs in CI.

Changes:

  • Added a public Locale(string? language, string? country, string? name, string? id) constructor with nullstring.Empty normalization and expanded XML docs.
  • Tightened voice-selection guards in platform TextToSpeech implementations to handle partially populated locales.
  • Added Essentials.ExternalBackend.UnitTests and wired it into solutions and test runs (Helix + cake).

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Essentials/test/ExternalBackendTests/ExternalTextToSpeechBackendTests.cs Adds “external backend” regression tests validating the new public Locale constructor and round-tripping via SpeechOptions.
src/Essentials/test/ExternalBackendTests/Essentials.ExternalBackend.UnitTests.csproj Introduces a new test project intentionally lacking InternalsVisibleTo access to validate public API usage.
src/Essentials/src/TextToSpeech/TextToSpeech.shared.cs Makes Locale constructor public, normalizes null arguments, and documents cross-platform semantics.
src/Essentials/src/TextToSpeech/TextToSpeech.android.cs Updates locale guard logic before constructing Android Java.Util.Locale.
src/Essentials/src/TextToSpeech/TextToSpeech.ios.tvos.watchos.cs Updates iOS voice selection to treat empty Id as absent.
src/Essentials/src/TextToSpeech/TextToSpeech.macos.cs Updates macOS voice selection to require non-empty Locale.Id.
src/Essentials/src/TextToSpeech/TextToSpeech.tizen.cs Avoids a potential null dereference / improves guard when Locale.Language is missing/empty.
src/Essentials/src/PublicAPI/net/PublicAPI.Unshipped.txt Records the newly public Locale constructor for API tracking.
src/Essentials/src/PublicAPI/netstandard/PublicAPI.Unshipped.txt Records the newly public Locale constructor for API tracking.
src/Essentials/src/PublicAPI/net-android/PublicAPI.Unshipped.txt Records the newly public Locale constructor for API tracking.
src/Essentials/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt Records the newly public Locale constructor for API tracking.
src/Essentials/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt Records the newly public Locale constructor for API tracking.
src/Essentials/src/PublicAPI/net-tizen/PublicAPI.Unshipped.txt Records the newly public Locale constructor for API tracking.
src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt Records the newly public Locale constructor for API tracking.
eng/helix.proj Adds the new external-backend unit test project to Helix execution.
eng/cake/dotnet.cake Includes the new unit test project in dotnet-test globs.
Microsoft.Maui.sln Registers the new test project in the main solution.
Microsoft.Maui-windows.slnf Adds the new test project to the Windows solution filter.
Microsoft.Maui-mac.slnf Adds the new test project to the Mac solution filter.

Comment on lines 129 to 135
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);
/// <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
Comment thread src/Essentials/test/ExternalBackendTests/ExternalTextToSpeechBackendTests.cs Outdated
Copilot stopped reviewing on behalf of Redth due to an error August 26, 2026 21:46
@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Aug 26, 2026
Fixes the macOS Debug/Release build break introduced by the previous commit:

  Microsoft.Maui-mac.slnf(1,1): error MSB4025: The project file could not
  be loaded. Data at the root level is invalid. Line 1, position 1.

Microsoft.Maui-mac.slnf and Microsoft.Maui-windows.slnf both filter
Microsoft.Maui-dev.sln, not Microsoft.Maui.sln. The new test project was
only added to Microsoft.Maui.sln, so the filter referenced a project that
its backing solution did not contain. Reproduced locally as:

  error MSB5028: Solution filter file at ".../Microsoft.Maui-mac.slnf"
  includes project "src/Essentials/test/ExternalBackendTests/
  Essentials.ExternalBackend.UnitTests.csproj" that is not in the solution
  file at ".../Microsoft.Maui-dev.sln".

Add the project to Microsoft.Maui-dev.sln, and to Microsoft.Maui-vscode.sln
for consistency with Essentials.UnitTests, which is listed in all three
solutions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 26, 2026 22:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Previously missed (1) — in code that hasn't changed since the last review.

src/Essentials/src/TextToSpeech/TextToSpeech.shared.cs:274

  • The XML docs have a grammatical error: "For Android this used..." should be present tense ("uses").

This issue also appears on line 286 of the same file.

		/// 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).

src/Essentials/src/TextToSpeech/TextToSpeech.shared.cs:286

  • The XML docs have a grammatical error: "For Android this used..." should be present tense ("uses").
		/// <para>For Android this used the ISO 3166 alpha-2 country code or UN M.49 numeric-3 area code.</para>

src/Essentials/test/ExternalBackendTests/ExternalTextToSpeechBackendTests.cs:55

  • The null-forgiving operator (value!) isn’t needed here (the Locale constructor accepts nullable strings) and makes the intent of the null-normalization test less clear. Passing value directly still tests the same runtime behavior without suppressing nullability warnings.
			var locale = new Locale(value!, value, value, value);

Code review of the public Locale constructor caught a regression the
constructor itself introduced, plus adjacent gaps in the same class of
newly-reachable input.

Windows (regression, was missed): GetSpeakParametersSSMLProsody resolved
the language with

    options?.Locale?.Language ?? SpeechSynthesizer.DefaultVoice.Language

Normalizing null to string.Empty defeats that ?? — an externally
constructed Locale with no language yields "", which is not null, so the
default-voice fallback silently stopped firing and the SSML was emitted
with xml:lang=''. Windows was the one platform not hardened in the
previous commit. Verified against a standalone harness: the old
expression emits xml:lang='' for new Locale(null, ...) and
new Locale("", ...); the new one falls back to DefaultVoice.Language for
null/empty/whitespace and preserves a real language unchanged.

iOS: a non-empty but unrecognized Id made AVSpeechSynthesisVoice
.FromIdentifier return null, and because that sat in the true branch of a
ternary with no coalesce, Language was skipped entirely and Voice was set
to null. Since the whole point of this PR is that out-of-tree backends
mint engine-specific ids, that path is now reachable. Rewritten as a
single coalescing chain so identifier -> language -> current-language
each falls through, matching the intent the comment already stated.

macOS: restore the reset semantics the previous commit changed. Assigning
null returns NSSpeechSynthesizer to the system default; the synthesizer is
a cached instance, so skipping the assignment let a voice from a previous
utterance leak into a call that asked for no specific voice. (This file is
not currently compiled for any shipping TFM.)

Use IsNullOrWhiteSpace consistently across the guards, so a whitespace-only
value is treated as absent like an empty one, matching the Country guard
that already did this.

Also drop the test that pinned reference equality and the compiler default
ToString(), and the matching sentence in the XML docs. Neither is part of
what this change establishes, and asserting them would block a future
record/IEquatable conversion. Document instead that empty or whitespace
properties are treated as "not specified" by the platform backends.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 27, 2026 00:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Essentials/src/TextToSpeech/TextToSpeech.shared.cs:317

  • The constructor XML docs say "No argument is required", which is misleading because the constructor has four required parameters (even though they can be null). Reword to clarify that arguments are optional in value (may be null) rather than optional in arity.
		/// 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>

@MauiBot

MauiBot commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

AI Review Summary

@Redth — new AI review results are available based on commit df590d4.

Gate No Tests Confidence Low Platform Android


🗂️ Review Sessions — click to expand

[!WARNING]
This run reviewed commit df590d4, but the PR advanced to a0301f1 while it was running. These results are informational; re-run /review for the current head.


🚦 Gate — Test Before & After Fix

Gate Result: ⚠️ SKIPPED

No tests were detected in this PR.

Recommendation: Add tests to verify the fix using the write-tests-agent.


📋 Pre-Flight — Context & Validation

Pre-Flight: PR #37857

Context

  • Title: [Essentials] Make Locale constructible by external TextToSpeech backends
  • Base / head: net11.0 (bedd1b18b7682193e05b47267509cec8c49c6853) -> redth-essentials-locale-public-construction (df590d4e2c7bc08cc35bad70d8b7fe2bb5d7ea75)
  • Materialized review commit: ceaeb8de1b on pr-review-37857
  • Problem: Microsoft.Maui.Media.Locale has only an internal constructor. An out-of-tree ITextToSpeech implementation cannot construct values for GetLocalesAsync or SpeechOptions.Locale; external compilation fails with CS1729.
  • Concrete consumer: Redth/Maui.Tizen#8 currently cannot return supported locales despite the Tizen engine exposing them.
  • Target platform for this phase: Android.

Current PR Approach

The PR makes the existing four-argument Locale constructor public and nullable, normalizes null constructor arguments to string.Empty, documents the public contract, and adds the constructor to every applicable PublicAPI.Unshipped.txt. It also changes Android, Apple, macOS, and Tizen voice-selection guards from null checks to empty-aware checks so externally created partial locales fall back safely.

The PR adds a separate Essentials.ExternalBackend.UnitTests assembly without InternalsVisibleTo. Its seven tests exercise external construction, null normalization, verbatim values, locale enumeration, SpeechOptions round-tripping, reference equality, and default ToString(). The project is wired into the main solutions, Cake unit-test discovery, and Helix.

Diff Surface

  • 21 files, +226/-13.
  • Production/API files:
    • src/Essentials/src/TextToSpeech/TextToSpeech.shared.cs
    • src/Essentials/src/TextToSpeech/TextToSpeech.android.cs
    • src/Essentials/src/TextToSpeech/TextToSpeech.ios.tvos.watchos.cs
    • src/Essentials/src/TextToSpeech/TextToSpeech.macos.cs
    • src/Essentials/src/TextToSpeech/TextToSpeech.tizen.cs
    • Seven src/Essentials/src/PublicAPI/*/PublicAPI.Unshipped.txt files
  • Added test files:
    • src/Essentials/test/ExternalBackendTests/Essentials.ExternalBackend.UnitTests.csproj
    • src/Essentials/test/ExternalBackendTests/ExternalTextToSpeechBackendTests.cs
  • Build registration changes affect five solution/filter files, eng/cake/dotnet.cake, and eng/helix.proj.

Test Contract

The prior gate result is authoritative: SKIPPED — no tests detected in this PR. Gate verification must not be rerun and gate/content.md must not be changed.

For a candidate that reaches testing, use only the focused external-backend project:

dotnet test src/Essentials/test/ExternalBackendTests/Essentials.ExternalBackend.UnitTests.csproj --no-restore

No additional mandatory regression test command was supplied for STEP 5a, so no broad Essentials or repository suite is permitted.

Try-Fix Constraints

Each candidate must use EstablishBrokenBaseline.ps1 and obey .github/.baseline-state.json. The PR adds two tracked files; if the script records either in NewFiles, the try-fix skill requires a Blocked result before any edit because restoration is not safe. Existing unrelated dirty files under .github/scripts, .github/skills, and eng/scripts are pre-existing workspace state and must not be modified, removed, reset, stashed, or included in a candidate.

Candidate 1 must avoid repeating the PR's public four-argument-constructor mechanism. Candidate 2 must additionally avoid Candidate 1's recorded mechanism and failure mode.


🔬 Code Review — Deep Analysis

Expert Evaluation of Submitted PR

Verdict: NEEDS_CHANGES

Confidence: low, because the trusted Gate was skipped and required-check status could not be retrieved with the unauthenticated CLI. The two blocking code findings are nevertheless concrete.

Independent assessment

The submitted PR adds the smallest direct API needed by out-of-tree ITextToSpeech implementations: a public four-argument Locale constructor. It also makes the existing non-null property contract true at runtime by normalizing null values, updates platform voice-selection guards for partially populated locales, and adds a separate external-consumer test assembly plus build/Helix registration.

The constructor shape, nullable annotations, PublicAPI.Unshipped.txt entries, external-assembly test boundary, and Android/Tizen empty-value guards are sound. The normalization is an intentional compatibility change already explained in the PR description and represented in the new constructor tests.

Actionable findings

  1. iOS invalid identifiers bypass the documented language fallback. At TextToSpeech.ios.tvos.watchos.cs:34, the ?? chain applies only to the conditional's false branch. A publicly constructed locale with a foreign non-empty Id selects the identifier branch; if FromIdentifier returns null, its valid Language is discarded.
  2. macOS can retain the previous call's voice. At TextToSpeech.macos.cs:33, skipping assignment for an empty Id leaves the shared NSSpeechSynthesizer.Voice unchanged. A language-only locale following an identified locale therefore speaks with the stale prior voice instead of restoring the default as the old null assignment did.
  3. The Locale.Id documentation overstates fallback behavior. Windows and macOS do not select by Language/Country when Id is empty. The contract should describe platform-specific fallback rather than promise this on every platform.

Non-blocking review observations

  • Null-to-empty normalization changes values returned by existing platform implementations, but it aligns runtime behavior with the shipped non-null API annotations and is explicitly documented by the submitted PR.
  • The external-backend tests cover public construction and transport, not the modified native voice-selection branches. Platform regression coverage remains a gap.
  • Prior review surfaces contained no unresolved error-level findings. Existing Copilot comments were suggestions about whitespace handling, wording, and an unnecessary null-forgiving operator; none disproves the submitted design.
  • External-output and Trim/AOT contract analysis are not applicable to this diff.

Recommended consolidated refinement

Preserve the submitted public-constructor approach, add a full iOS identifier-to-language-to-current-language fallback chain, explicitly reset the macOS voice for a locale with an empty identifier, and correct the cross-platform Id documentation.


🛠️ Try-Fix — Analysis & Comparison

Aggregate Try-Fix Results: PR #37857

Candidate 1 — Object Initializer with init Properties

Model: claude-opus-5
Result: Blocked
Detailed report: ../try-fix-1/content.md
Attempt artifacts: attempt-1/

Approach

Keep the existing four-argument constructor internal, add a public parameterless constructor, and expose Language, Country, Name, and Id as init-only properties initialized to string.Empty. External implementations would use named object-initializer assignments. This avoids the PR's public positional-constructor contract and makes omitted values empty through property defaults rather than constructor-argument normalization.

Diff

(no changes)

The approach was designed but not applied.

Test Result

The permitted command was:

dotnet test src/Essentials/test/ExternalBackendTests/Essentials.ExternalBackend.UnitTests.csproj --no-restore

It was not executed. The baseline was never established, so running it would have tested the existing PR rather than Candidate 1.

Block Analysis

EstablishBrokenBaseline.ps1 stopped because the worktree contains 44 pre-existing modified/deleted files under .github/scripts, .github/skills, and eng/scripts. Those files are out of scope and could not be cleaned. Consequently .github/.baseline-state.json was not created and there was no RevertedFiles edit allow-list. Independently, the PR adds two tracked external-backend test files, which would make NewFiles non-empty and trigger the try-fix skill's mandatory safe-restoration block.

Inline expert self-review found 0 findings against the empty diff. All eight attempt artifacts were written. The exact restore command ran and returned the expected no-state result (Restored False, No baseline state found); no attempt-created source changes remained.

Constraint for Candidate 2

Candidate 2 must avoid both the PR's public four-argument positional constructor and Candidate 1's public parameterless constructor plus init-property mechanism. Candidate 1's block is structural/environmental, not evidence that its proposed API shape works or fails.

Candidate 2 — Public Static Locale.Create Factory

Model: gpt-5.6-sol
Result: Blocked
Detailed report: ../try-fix-2/content.md
Attempt artifacts: attempt-2/

Approach

Keep all Locale constructors non-public and add a public static Locale.Create(language, country, name, id) factory. The factory would normalize null values and call the existing internal constructor. External backends could create locales without exposing a positional constructor or making locale properties externally assignable.

This differs from both prior mechanisms: unlike the PR, construction does not widen constructor visibility; unlike Candidate 1, it does not expose a parameterless construction phase or public init setters. The factory is the sole public creation boundary, so normalization and future validation can remain centralized while the immutable property surface stays unchanged.

Diff

(no changes)

The approach was designed but not applied.

Test Result

The permitted command was:

dotnet test src/Essentials/test/ExternalBackendTests/Essentials.ExternalBackend.UnitTests.csproj --no-restore

It was not executed because no candidate diff could safely be applied after baseline failure.

Block Analysis

Candidate 2 independently ran the mandatory baseline step. EstablishBrokenBaseline.ps1 again rejected the 44 pre-existing modified/deleted harness files, leaving .github/.baseline-state.json absent and therefore providing no safe edit allow-list. The PR's two added tracked test files also remain an independent NewFiles restoration boundary. The agent did not clean the workspace, alter Candidate 1, or bypass either guard.

Inline expert self-review found 0 findings against the empty diff. Required attempt artifacts were written under attempt-2/. The exact restore command ran and returned the expected no-state result (Restored False); no attempt-created source changes remained.

STEP 5a Outcome

Two distinct alternatives were bounded and recorded, but neither became a tested fix candidate because the mandatory baseline could not safely materialize the broken state. No candidate passed or failed behaviorally; both results are Blocked, and both diffs are empty. STEP 5b should treat the API-shape ideas as unvalidated design alternatives, not working patches.


📝 PR Finalize — Recommended Title & Description

Assessment: ✏️ Recommend updating — the current description overstates cross-platform identifier fallback and says in-box behavior is unchanged even though platform-produced null properties now surface as empty strings.

Recommended title

[Essentials] TextToSpeech: Make Locale constructible by external backends

Recommended description

### Description of Change

`Microsoft.Maui.Media.Locale` only had an **internal** constructor. That makes it impossible for a text-to-speech backend that lives outside of this repo to implement `ITextToSpeech`: it cannot return anything from `GetLocalesAsync()`, and it cannot construct a `Locale` to hand back through `SpeechOptions.Locale`.

This is a concrete blocker today — [Redth/Maui.Tizen#8](https://github.com/Redth/Maui.Tizen/pull/8) has to mark locale enumeration as unsupported (`GetLocalesAsync` returns an empty list) purely because of this, even though the Tizen `TtsClient.GetSupportedVoices()` API returns everything needed.

Reproduction from an assembly that does **not** have `InternalsVisibleTo` access to `Microsoft.Maui.Essentials`:

error CS1729: 'Locale' does not contain a constructor that takes 4 arguments


#### What changed

1. **`Locale` gains a public constructor** (`public Locale(string? language, string? country, string? name, string? id)`). This is the smallest additive change that unblocks the scenario — no builder, no factory, no new type.

2. **`null` is normalized to `string.Empty`.** `PublicAPI.Shipped.txt` already declares all four properties as non-nullable (`string!`), but the in-box platform backends pass `null` for `Country`/`Name`/`Id`. The constructor now enforces the declared contract, so consumers can rely on the properties never being `null`. This intentionally changes those platform-produced null property values to empty strings. Values are otherwise stored verbatim — they are **not** parsed, validated, trimmed, or case normalized, because their meaning is defined by whichever engine produced them.

3. **Immutability, reference equality, and `ToString()` are unchanged.** No `Equals`/`GetHashCode`/`ToString` overrides were added, so existing behavior is preserved; there is a test that pins this.

4. **Platform voice-selection guards now account for empty values.** The Android, iOS, macOS, and Tizen checks were changed from null-only checks to `string.IsNullOrEmpty` before native voice lookup. Android and Tizen skip language lookup when `Language` is empty; iOS selects by identifier when it is non-empty and otherwise follows its language/current-language path; macOS only assigns a non-empty identifier.
   - This also fixes a latent `NullReferenceException` on Tizen, where `options?.Locale.Language` dereferenced a null `Locale`.

5. **Culture semantics are documented.** The XML docs state that `Language` is BCP-47 on iOS/Windows and ISO 639 on Android, that `Country` is ISO 3166 / UN M.49 on Android and unused (empty) on iOS/Windows because the region is already carried in `Language`, that `Id` is engine-specific, and that `null` normalizes to `string.Empty`.

### Issues Fixed

Unblocks out-of-tree `ITextToSpeech` backends, e.g. [Redth/Maui.Tizen#8](https://github.com/Redth/Maui.Tizen/pull/8).

### Testing

Added `src/Essentials/test/ExternalBackendTests` (`Microsoft.Maui.Essentials.ExternalBackend.UnitTests`). This assembly deliberately has **no** `InternalsVisibleTo` grant from `Microsoft.Maui.Essentials`, so it can only use the public surface — it is the regression guard for exactly the scenario this PR unblocks. It contains a stand-in external backend that implements `ITextToSpeech`, and covers:

- the constructor being reachable from outside Essentials;
- `null` and `""` normalizing to `string.Empty`;
- values not being trimmed or case normalized;
- an external backend returning `Locale`s from `GetLocalesAsync()`, including a language-only locale;
- round-tripping a `Locale` through `SpeechOptions` into `SpeakAsync`;
- reference equality and the default `ToString()` being preserved.

The project is registered in `eng/helix.proj`, `eng/cake/dotnet.cake`, `Microsoft.Maui.sln`, `Microsoft.Maui-dev.sln`, `Microsoft.Maui-vscode.sln`, `Microsoft.Maui-mac.slnf`, and `Microsoft.Maui-windows.slnf`, so it runs alongside the other unit test assemblies.

Verified locally on macOS:

- `Essentials.ExternalBackend.UnitTests` — 7/7 passed (and confirmed to fail to compile with `CS1729` before the change).
- `Essentials.UnitTests` — 513/513 passed.
- `Essentials.csproj` builds clean (including the `PublicAPI` analyzers) for `net11.0`, `netstandard2.0`, `netstandard2.1`, `net11.0-ios`, `net11.0-maccatalyst`, and `net11.0-android`.

`PublicAPI.Unshipped.txt` entries were added for all seven TFM folders (`net`, `net-android`, `net-ios`, `net-maccatalyst`, `net-tizen`, `net-windows`, `netstandard`).

🏁 Report — Final Recommendation

⚠️ Final Recommendation: REQUEST CHANGES

Winner: pr-plus-reviewer

The submitted public-constructor design is the strongest API approach, but the raw PR contains two concrete native voice-selection defects. The consolidated reviewer patch fixes both without changing the API design or broadening scope. Its one permitted focused validation run was blocked by a missing NuGet assets file, so the recommendation carries explicit test uncertainty rather than treating the command as a pass.

Comparative ranking

Rank Candidate Implementation Validation Assessment
1 pr-plus-reviewer Complete PR plus focused reviewer corrections Blocked by missing project.assets.json; no behavioral test result Best code: retains the minimal API while fixing iOS fallback, macOS stale voice state, and inaccurate fallback documentation.
2 pr Complete submitted fix Trusted Gate SKIPPED; no detected tests Correct core API and external-consumer test design, but an unknown iOS identifier discards a valid language and a macOS language-only locale can retain the previous voice.
3 try-fix-2 Design only; empty diff Blocked, not executed A static factory preserves immutability, but it is unimplemented, untested, and less direct than ordinary construction for a data-holder type. It also does not resolve the reviewed platform behavior.
4 try-fix-1 Design only; empty diff Blocked, not executed A parameterless constructor plus four public init accessors greatly expands the mutable-looking API surface and is unimplemented and untested. Its claim that platform guards could remain unchanged is unsafe once empty values become valid.

No candidate passed or failed a regression test: the trusted Gate skipped the raw PR, both STEP 5a alternatives stopped before implementation, and pr-plus-reviewer stopped at missing restore assets before compiling. Therefore no failed candidate is ranked above a passing candidate; there are no passing candidates in the available evidence.

Expert review reconciliation

  • Blocking: iOS's conditional/?? grouping gives the non-empty identifier branch no language fallback when AVSpeechSynthesisVoice.FromIdentifier returns null.
  • Blocking: macOS's shared NSSpeechSynthesizer retains its previous Voice when a new locale has an empty identifier.
  • Corrected: the raw XML documentation promises language/country fallback on platforms that actually use the platform default.
  • Accepted design tradeoff: null-to-empty normalization changes values observed from existing platform-produced locales, but it enforces the shipped non-null property contract and is explicitly documented and tested at the constructor boundary.
  • Remaining gap: the new external-backend tests validate public construction and transport, not native voice-selection branches.

inline-findings.json contains the raw submitted-PR findings. pr-plus-reviewer/reviewer.patch, candidate.patch, and validation.log contain the complete refinement evidence.


📱 UI Tests — Button,Label,Layout

Detected UI test categories: Button,Label,Layout

Deep UI tests — 360 passed, 0 failed, 7 skipped across 3 categories on platform-pool agent (replaces in-process counts above).

🧪 UI Test Execution Results (deep, platform pool)

Category Tests Snapshot diffs
Button 71/73 (2 skipped) ✓
Label 97/99 (2 skipped) ✓
Layout 192/195 (3 skipped) ✓
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs)

🧭 Next Steps — reviewer changes required

The reviewer-enhanced candidate identified changes that are not yet in the submitted PR.

Why: The submitted public-constructor approach is the best API design, but the reviewer refinement also fixes concrete iOS fallback and macOS stale-voice defects while correcting the platform contract documentation. Its focused validation was blocked by a missing assets file, so native behavior remains an explicit uncertainty.

Address the actionable findings in this review before merging.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Aug 27, 2026
Build Windows (Release) in build 1569551 failed to compile
src/Essentials/src/Passkeys/WindowsWebAuthn.windows.cs with CS0234/CS0246
for Windows.Win32, HRESULT, PCWSTR and the WEBAUTHN_* structs, i.e. the
Microsoft.Windows.CsWin32 source generator did not emit its output.

This is unrelated to the change under review:

  * this PR touches no Passkeys, CsWin32 or NativeMethods code;
  * no error in the log came from TextToSpeech.windows.cs;
  * Essentials compiled cleanly for net11.0, netstandard2.0/2.1,
    maccatalyst and android in the same run - only the two Windows TFMs
    failed, and only on the generated namespace;
  * Build Windows (Debug) compiled the same file successfully in the same
    build on a different agent (NetCore-Public 128 vs 111);
  * Build Windows (Release) succeeded two runs earlier on build 1569387,
    which already had the identical solution graph including the new test
    project - the only delta since is the TextToSpeech guard fixes;
  * no other build in the recent fleet shows this signature.

The step retried on the same agent and failed identically, which points at
that agent rather than at a race. Pushing an empty commit to get the job
scheduled somewhere else.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-essentials Essentials: Device, Display, Connectivity, Secure Storage, Sensors, App Info platform/android

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants