Skip to content

Commit 2522ae4

Browse files
[Essentials] Add microphone permission handling on Windows and tests (#31451)
* [Essentials] Add microphone permission handling on Windows and tests * Adjust changes after review * Add additional microphone permission tests
1 parent 860567e commit 2522ae4

File tree

7 files changed

+154
-0
lines changed

7 files changed

+154
-0
lines changed

src/Essentials/src/Permissions/Permissions.windows.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Windows.ApplicationModel.Contacts;
1212
using Windows.Devices.Enumeration;
1313
using Windows.Devices.Geolocation;
14+
using Windows.Media.Capture;
1415

1516
namespace Microsoft.Maui.ApplicationModel
1617
{
@@ -173,6 +174,57 @@ public partial class Media : BasePlatformPermission
173174

174175
public partial class Microphone : BasePlatformPermission
175176
{
177+
/// <inheritdoc/>
178+
protected override Func<IEnumerable<string>> RequiredDeclarations => () => ["microphone"];
179+
180+
/// <inheritdoc/>
181+
public override Task<PermissionStatus> CheckStatusAsync()
182+
{
183+
EnsureDeclared();
184+
return Task.FromResult(CheckStatus() switch
185+
{
186+
DeviceAccessStatus.Allowed => PermissionStatus.Granted,
187+
DeviceAccessStatus.DeniedBySystem => PermissionStatus.Denied,
188+
DeviceAccessStatus.DeniedByUser => PermissionStatus.Denied,
189+
_ => PermissionStatus.Unknown,
190+
});
191+
}
192+
193+
/// <inheritdoc/>
194+
public override async Task<PermissionStatus> RequestAsync()
195+
{
196+
EnsureDeclared();
197+
198+
// If already explicitly allowed, return that
199+
var status = CheckStatus();
200+
if (status == DeviceAccessStatus.Allowed)
201+
return PermissionStatus.Granted;
202+
203+
try
204+
{
205+
var settings = new MediaCaptureInitializationSettings
206+
{
207+
StreamingCaptureMode = StreamingCaptureMode.Audio
208+
};
209+
210+
using (var mediaCapture = new MediaCapture())
211+
{
212+
await mediaCapture.InitializeAsync(settings);
213+
return PermissionStatus.Granted;
214+
}
215+
}
216+
catch (UnauthorizedAccessException)
217+
{
218+
return PermissionStatus.Denied;
219+
}
220+
catch
221+
{
222+
return PermissionStatus.Unknown;
223+
}
224+
}
225+
226+
private DeviceAccessStatus CheckStatus()
227+
=> DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.AudioCapture).CurrentStatus;
176228
}
177229

178230
public partial class NearbyWifiDevices : BasePlatformPermission

src/Essentials/src/PublicAPI/net-windows/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ Microsoft.Maui.Media.SpeechOptions.Rate.get -> float?
1919
Microsoft.Maui.Media.SpeechOptions.Rate.set -> void
2020
*REMOVED*Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<Microsoft.Maui.Storage.FileResult!>!>!
2121
Microsoft.Maui.Storage.IFilePicker.PickMultipleAsync(Microsoft.Maui.Storage.PickOptions? options = null) -> System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<Microsoft.Maui.Storage.FileResult?>!>!
22+
~override Microsoft.Maui.ApplicationModel.Permissions.Microphone.CheckStatusAsync() -> System.Threading.Tasks.Task<Microsoft.Maui.ApplicationModel.PermissionStatus>
23+
~override Microsoft.Maui.ApplicationModel.Permissions.Microphone.RequestAsync() -> System.Threading.Tasks.Task<Microsoft.Maui.ApplicationModel.PermissionStatus>
24+
~override Microsoft.Maui.ApplicationModel.Permissions.Microphone.RequiredDeclarations.get -> System.Func<System.Collections.Generic.IEnumerable<string>>
2225
static Microsoft.Maui.Authentication.WebAuthenticator.AuthenticateAsync(Microsoft.Maui.Authentication.WebAuthenticatorOptions! webAuthenticatorOptions, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.WebAuthenticatorResult!>!
2326
static Microsoft.Maui.Authentication.WebAuthenticator.AuthenticateAsync(System.Uri! url, System.Uri! callbackUrl, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.WebAuthenticatorResult!>!
2427
static Microsoft.Maui.Authentication.WebAuthenticatorExtensions.AuthenticateAsync(this Microsoft.Maui.Authentication.IWebAuthenticator! webAuthenticator, System.Uri! url, System.Uri! callbackUrl, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task<Microsoft.Maui.Authentication.WebAuthenticatorResult!>!

src/Essentials/test/DeviceTests/Platforms/Android/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
<uses-permission android:name="android.permission.VIBRATE" />
1515
<uses-permission android:name="android.permission.READ_CONTACTS" />
1616
<uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS" />
17+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
1718
<queries>
1819
<!-- Email -->
1920
<intent>

src/Essentials/test/DeviceTests/Platforms/MacCatalyst/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,7 @@
3030
<string>Access to your location is required for cool things to happen!</string>
3131
<key>NSContactsUsageDescription</key>
3232
<string>Contacts</string>
33+
<key>NSMicrophoneUsageDescription</key>
34+
<string>Microphone access is required for microphone tests.</string>
3335
</dict>
3436
</plist>

src/Essentials/test/DeviceTests/Platforms/Windows/Package.appxmanifest

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838

3939
<Capabilities>
4040
<rescap:Capability Name="runFullTrust" />
41+
<!-- Microphone device capability for Essentials.DeviceTests -->
42+
<DeviceCapability Name="microphone" />
4143
</Capabilities>
4244

4345
</Package>

src/Essentials/test/DeviceTests/Platforms/iOS/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,7 @@
3232
<string>Access to your location is required for cool things to happen!</string>
3333
<key>NSContactsUsageDescription</key>
3434
<string>Contacts</string>
35+
<key>NSMicrophoneUsageDescription</key>
36+
<string>Microphone access is required for microphone tests.</string>
3537
</dict>
3638
</plist>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Threading.Tasks;
2+
using Microsoft.Maui.ApplicationModel;
3+
using Xunit;
4+
5+
namespace Microsoft.Maui.Essentials.DeviceTests;
6+
7+
[Category("Permissions")]
8+
public class Microphone_Tests
9+
{
10+
[Fact]
11+
[Trait(Traits.InteractionType, Traits.InteractionTypes.Human)]
12+
public async Task Request_Microphone_Denied_Returns_Denied()
13+
{
14+
// If microphone permission was previously granted, the OS won't show a new prompt.
15+
// In that case we can't exercise the prompt flow, so skip the test.
16+
var initial = await Permissions.CheckStatusAsync<Permissions.Microphone>();
17+
if (initial == PermissionStatus.Granted)
18+
return; // nothing to prompt; skip
19+
20+
// This test requires human interaction: When prompted, choose Deny for microphone access
21+
var status = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<Permissions.Microphone>);
22+
Assert.Equal(PermissionStatus.Denied, status);
23+
24+
// And subsequent checks should reflect Denied
25+
var check = await Permissions.CheckStatusAsync<Permissions.Microphone>();
26+
Assert.Equal(PermissionStatus.Denied, check);
27+
}
28+
29+
[Fact]
30+
[Trait(Traits.InteractionType, Traits.InteractionTypes.Human)]
31+
public async Task Request_Microphone_Allowed_Returns_Granted()
32+
{
33+
// If microphone permission was previously granted, the OS won't show a new prompt.
34+
// In that case we can't exercise the prompt flow, so skip the test.
35+
var initial = await Permissions.CheckStatusAsync<Permissions.Microphone>();
36+
if (initial == PermissionStatus.Granted)
37+
return; // nothing to prompt; skip
38+
39+
// This test requires human interaction: When prompted, choose Allow for microphone access
40+
var status = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<Permissions.Microphone>);
41+
Assert.Equal(PermissionStatus.Granted, status);
42+
43+
// And subsequent checks should reflect Granted
44+
var check = await Permissions.CheckStatusAsync<Permissions.Microphone>();
45+
Assert.Equal(PermissionStatus.Granted, check);
46+
}
47+
48+
[Fact]
49+
[Trait(Traits.InteractionType, Traits.InteractionTypes.Human)]
50+
public async Task Request_Microphone_Granted_Then_RequestAgain_Returns_Granted()
51+
{
52+
// Ensure we have the Granted state first (may require human interaction on first request)
53+
var status = await Permissions.CheckStatusAsync<Permissions.Microphone>();
54+
if (status != PermissionStatus.Granted)
55+
{
56+
// Human: choose Allow when prompted
57+
status = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<Permissions.Microphone>);
58+
if (status != PermissionStatus.Granted)
59+
return; // Can't proceed with this test if user denied; treat as skip
60+
}
61+
62+
// Second request should short-circuit and still return Granted without a new prompt
63+
var second = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<Permissions.Microphone>);
64+
Assert.Equal(PermissionStatus.Granted, second);
65+
66+
// Status check remains Granted
67+
var check = await Permissions.CheckStatusAsync<Permissions.Microphone>();
68+
Assert.Equal(PermissionStatus.Granted, check);
69+
}
70+
71+
[Fact]
72+
[Trait(Traits.InteractionType, Traits.InteractionTypes.Human)]
73+
public async Task Request_Microphone_Denied_Then_RequestAgain_Returns_Denied()
74+
{
75+
// If already granted we can't meaningfully test denied flow again without application/OS settings reset
76+
var initial = await Permissions.CheckStatusAsync<Permissions.Microphone>();
77+
if (initial == PermissionStatus.Granted)
78+
return; // skip
79+
80+
// First request: Human choose Deny when prompted (if prompt appears)
81+
var first = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<Permissions.Microphone>);
82+
if (first != PermissionStatus.Denied)
83+
return; // can't assert repeat deny path if user allowed or unknown
84+
85+
// Second request should return Denied again (idempotent) and not elevate permission
86+
var second = await MainThread.InvokeOnMainThreadAsync(Permissions.RequestAsync<Permissions.Microphone>);
87+
Assert.Equal(PermissionStatus.Denied, second);
88+
89+
var check = await Permissions.CheckStatusAsync<Permissions.Microphone>();
90+
Assert.Equal(PermissionStatus.Denied, check);
91+
}
92+
}

0 commit comments

Comments
 (0)