Skip to content

Commit b48667d

Browse files
committed
Defer Bluetooth permission and improve support
1 parent c7ff9fe commit b48667d

17 files changed

Lines changed: 622 additions & 96 deletions

File tree

BrickController2/BrickController2.MacCatalyst/Info.plist

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@
1515
<key>CFBundleShortVersionString</key>
1616
<string>3.5</string>
1717
<key>CFBundleVersion</key>
18-
<string>52</string>
18+
<string>53</string>
1919
<key>ITSAppUsesNonExemptEncryption</key>
2020
<false/>
2121
<key>LSApplicationCategoryType</key>
2222
<string>public.app-category.utilities</string>
2323
<key>NSBluetoothAlwaysUsageDescription</key>
24-
<string>Bluetooth access is required to use SBrick, BuWizz or Powered-Up devices.</string>
24+
<string>BrickController uses Bluetooth when you choose to find, connect to, and control compatible receivers and remotes.</string>
2525
<key>NSBluetoothPeripheralUsageDescription</key>
26-
<string>Bluetooth access is required to use SBrick, BuWizz or Powered-Up devices.</string>
26+
<string>BrickController uses Bluetooth when you choose to find, connect to, and control compatible receivers and remotes.</string>
2727
<key>NSCameraUsageDescription</key>
2828
<string>Camera is required in order to import a creation via QR code from another application.</string>
2929
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using BrickController2.PlatformServices.Permission;
2+
using BrickController2.UI.Services.Dialog;
3+
using BrickController2.UI.Services.Permission;
4+
using BrickController2.UI.Services.Preferences;
5+
using BrickController2.UI.Services.Translation;
6+
using FluentAssertions;
7+
using Microsoft.Maui.ApplicationModel;
8+
using Moq;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Xunit;
12+
13+
namespace BrickController2.Tests.UI.Services;
14+
15+
public class BluetoothPermissionGateTests
16+
{
17+
[Fact]
18+
public async Task EnsureAccessAsync_FirstDeviceAction_RequestsAndRemembersPermission()
19+
{
20+
var decision = BluetoothPermissionDecision.NotRequested;
21+
var permission = new Mock<IBluetoothPermission>();
22+
var dialogs = new Mock<IDialogService>();
23+
var preferences = CreatePreferencesMock(() => decision, value => decision = value);
24+
var translations = CreateTranslationMock();
25+
26+
permission.Setup(x => x.CheckStatusAsync()).ReturnsAsync(PermissionStatus.Unknown);
27+
permission.Setup(x => x.RequestAsync()).ReturnsAsync(PermissionStatus.Granted);
28+
dialogs.Setup(x => x.ShowQuestionDialogAsync(
29+
It.IsAny<string>(),
30+
It.IsAny<string>(),
31+
It.IsAny<string>(),
32+
It.IsAny<string>(),
33+
It.IsAny<CancellationToken>()))
34+
.ReturnsAsync(true);
35+
36+
var gate = new BluetoothPermissionGate(permission.Object, dialogs.Object, preferences.Object, translations.Object);
37+
38+
var result = await gate.EnsureAccessAsync(false, CancellationToken.None);
39+
40+
result.Should().BeTrue();
41+
decision.Should().Be(BluetoothPermissionDecision.Requested);
42+
permission.Verify(x => x.RequestAsync(), Times.Once);
43+
}
44+
45+
[Fact]
46+
public async Task EnsureAccessAsync_DeclinedDeviceAction_DoesNotAskAgain()
47+
{
48+
var decision = BluetoothPermissionDecision.NotRequested;
49+
var permission = new Mock<IBluetoothPermission>();
50+
var dialogs = new Mock<IDialogService>();
51+
var preferences = CreatePreferencesMock(() => decision, value => decision = value);
52+
var translations = CreateTranslationMock();
53+
54+
permission.Setup(x => x.CheckStatusAsync()).ReturnsAsync(PermissionStatus.Unknown);
55+
dialogs.Setup(x => x.ShowQuestionDialogAsync(
56+
It.IsAny<string>(),
57+
It.IsAny<string>(),
58+
It.IsAny<string>(),
59+
It.IsAny<string>(),
60+
It.IsAny<CancellationToken>()))
61+
.ReturnsAsync(false);
62+
dialogs.Setup(x => x.ShowMessageBoxAsync(
63+
It.IsAny<string>(),
64+
It.IsAny<string>(),
65+
It.IsAny<string>(),
66+
It.IsAny<CancellationToken>()))
67+
.Returns(Task.CompletedTask);
68+
69+
var gate = new BluetoothPermissionGate(permission.Object, dialogs.Object, preferences.Object, translations.Object);
70+
71+
(await gate.EnsureAccessAsync(false, CancellationToken.None)).Should().BeFalse();
72+
(await gate.EnsureAccessAsync(false, CancellationToken.None)).Should().BeFalse();
73+
74+
decision.Should().Be(BluetoothPermissionDecision.Declined);
75+
dialogs.Verify(x => x.ShowQuestionDialogAsync(
76+
It.IsAny<string>(),
77+
It.IsAny<string>(),
78+
It.IsAny<string>(),
79+
It.IsAny<string>(),
80+
It.IsAny<CancellationToken>()), Times.Once);
81+
dialogs.Verify(x => x.ShowMessageBoxAsync(
82+
It.IsAny<string>(),
83+
It.IsAny<string>(),
84+
It.IsAny<string>(),
85+
It.IsAny<CancellationToken>()), Times.Once);
86+
permission.Verify(x => x.RequestAsync(), Times.Never);
87+
}
88+
89+
private static Mock<IPreferencesService> CreatePreferencesMock(
90+
System.Func<BluetoothPermissionDecision> getDecision,
91+
System.Action<BluetoothPermissionDecision> setDecision)
92+
{
93+
var preferences = new Mock<IPreferencesService>();
94+
preferences.Setup(x => x.Get(
95+
It.IsAny<string>(),
96+
BluetoothPermissionDecision.NotRequested,
97+
It.IsAny<string>()))
98+
.Returns(getDecision);
99+
preferences.Setup(x => x.Set(
100+
It.IsAny<string>(),
101+
It.IsAny<BluetoothPermissionDecision>(),
102+
It.IsAny<string>()))
103+
.Callback<string, BluetoothPermissionDecision, string?>((_, value, _) => setDecision(value));
104+
return preferences;
105+
}
106+
107+
private static Mock<ITranslationService> CreateTranslationMock()
108+
{
109+
var translations = new Mock<ITranslationService>();
110+
translations.Setup(x => x.Translate(It.IsAny<string>())).Returns<string>(key => key);
111+
return translations;
112+
}
113+
}

BrickController2/BrickController2.iOS/Info.plist

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
<key>CFBundleName</key>
3232
<string>BrickController</string>
3333
<key>NSBluetoothPeripheralUsageDescription</key>
34-
<string>Bluetooth access is required to use SBrick, BuWizz or Powered-Up devices.</string>
34+
<string>BrickController uses Bluetooth when you choose to find, connect to, and control compatible receivers and remotes.</string>
3535
<key>NSCalendarsUsageDescription</key>
3636
<string>Allow the application to access the calendar.</string>
3737
<key>NSLocationAlwaysUsageDescription</key>
@@ -45,11 +45,11 @@
4545
<key>CFBundleShortVersionString</key>
4646
<string>3.5</string>
4747
<key>CFBundleVersion</key>
48-
<string>52</string>
48+
<string>53</string>
4949
<key>ITSAppUsesNonExemptEncryption</key>
5050
<false/>
5151
<key>NSBluetoothAlwaysUsageDescription</key>
52-
<string>Bluetooth access is required to use SBrick, BuWizz or Powered-Up devices.</string>
52+
<string>BrickController uses Bluetooth when you choose to find, connect to, and control compatible receivers and remotes.</string>
5353
<key>NSContactsUsageDescription</key>
5454
<string>Allow the application to access contacts (actually not used by the app)</string>
5555
<key>NSMicrophoneUsageDescription</key>

BrickController2/BrickController2.iOS/PlatformServices/BluetoothLE/BluetoothLEService.cs

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,25 @@ namespace BrickController2.iOS.PlatformServices.BluetoothLE
1515
{
1616
public class BluetoothLEService : CBCentralManagerDelegate, IBluetoothLEService
1717
{
18-
private readonly CBCentralManager _centralManager;
18+
private CBCentralManager? _centralManager;
19+
private TaskCompletionSource<CBManagerState>? _initialStateCompletionSource;
1920
private readonly IDictionary<CBPeripheral, BluetoothLEDevice> _peripheralMap = new Dictionary<CBPeripheral, BluetoothLEDevice>();
2021
private readonly object _lock = new();
2122

2223
private Action<ScanResult>? _scanCallback;
2324

24-
public BluetoothLEService()
25+
public Task<bool> IsBluetoothLESupportedAsync() => Task.FromResult(true);
26+
public Task<bool> IsBluetoothLEAdvertisingSupportedAsync() => Task.FromResult(true);
27+
public async Task<bool> IsBluetoothOnAsync()
2528
{
26-
#pragma warning disable CA1422 // Validate platform compatibility
27-
_centralManager = new CBCentralManager(this, DispatchQueue.CurrentQueue);
28-
#pragma warning restore CA1422 // Validate platform compatibility
29+
var centralManager = await GetCentralManagerAsync();
30+
return centralManager.State == CBManagerState.PoweredOn;
2931
}
3032

31-
public Task<bool> IsBluetoothLESupportedAsync() => Task.FromResult(true);
32-
public Task<bool> IsBluetoothLEAdvertisingSupportedAsync() => Task.FromResult(true);
33-
public Task<bool> IsBluetoothOnAsync() => Task.FromResult(_centralManager.State == CBManagerState.PoweredOn);
3433
public async Task<bool> ScanDevicesAsync(Action<ScanResult> scanCallback, CancellationToken token)
3534
{
36-
if (!await IsBluetoothLESupportedAsync() || !await IsBluetoothOnAsync() || _centralManager.IsScanning)
35+
var centralManager = await GetCentralManagerAsync();
36+
if (!await IsBluetoothLESupportedAsync() || centralManager.State != CBManagerState.PoweredOn || centralManager.IsScanning)
3737
{
3838
return false;
3939
}
@@ -44,35 +44,42 @@ public async Task<bool> ScanDevicesAsync(Action<ScanResult> scanCallback, Cancel
4444
{
4545
lock (_lock)
4646
{
47-
_centralManager.StopScan();
47+
centralManager.StopScan();
4848
_scanCallback = null;
4949
tcs.TrySetResult(true);
5050
}
5151
}))
5252
{
5353
_scanCallback = scanCallback;
54-
_centralManager.ScanForPeripherals(Array.Empty<CBUUID>(), new PeripheralScanningOptions { AllowDuplicatesKey = true });
54+
centralManager.ScanForPeripherals(Array.Empty<CBUUID>(), new PeripheralScanningOptions { AllowDuplicatesKey = true });
5555

5656
return await tcs.Task;
5757
}
5858
}
5959

60-
public Task<IBluetoothLEDevice?> GetKnownDeviceAsync(string address)
60+
public async Task<IBluetoothLEDevice?> GetKnownDeviceAsync(string address)
6161
{
62-
var peripheral = _centralManager?.RetrievePeripheralsWithIdentifiers(new NSUuid(address)).FirstOrDefault();
62+
var centralManager = await GetCentralManagerAsync();
63+
if (centralManager.State != CBManagerState.PoweredOn)
64+
{
65+
return default;
66+
}
67+
68+
var peripheral = centralManager.RetrievePeripheralsWithIdentifiers(new NSUuid(address)).FirstOrDefault();
6369
if (peripheral is null)
6470
{
65-
return Task.FromResult<IBluetoothLEDevice?>(default);
71+
return default;
6672
}
6773

68-
var device = new BluetoothLEDevice(_centralManager!, peripheral);
74+
var device = new BluetoothLEDevice(centralManager, peripheral);
6975
_peripheralMap[peripheral] = device;
7076

71-
return Task.FromResult<IBluetoothLEDevice?>(device);
77+
return device;
7278
}
7379

7480
public override void UpdatedState(CBCentralManager central)
7581
{
82+
_initialStateCompletionSource?.TrySetResult(central.State);
7683
}
7784

7885
public override void DiscoveredPeripheral(CBCentralManager central, CBPeripheral peripheral, NSDictionary advertisementData, NSNumber RSSI)
@@ -181,5 +188,34 @@ private Dictionary<byte, byte[]> ProcessAdvertisementData(NSDictionary advertise
181188
{
182189
return new BluetoothLEAdvertiserDevice();
183190
}
191+
192+
private async Task<CBCentralManager> GetCentralManagerAsync()
193+
{
194+
CBCentralManager centralManager;
195+
Task<CBManagerState>? initialStateTask;
196+
197+
lock (_lock)
198+
{
199+
if (_centralManager is null)
200+
{
201+
_initialStateCompletionSource = new TaskCompletionSource<CBManagerState>(TaskCreationOptions.RunContinuationsAsynchronously);
202+
#pragma warning disable CA1422 // Validate platform compatibility
203+
_centralManager = new CBCentralManager(this, DispatchQueue.MainQueue);
204+
#pragma warning restore CA1422 // Validate platform compatibility
205+
}
206+
207+
centralManager = _centralManager;
208+
initialStateTask = centralManager.State is CBManagerState.Unknown or CBManagerState.Resetting
209+
? _initialStateCompletionSource?.Task
210+
: null;
211+
}
212+
213+
if (initialStateTask is not null)
214+
{
215+
await initialStateTask;
216+
}
217+
218+
return centralManager;
219+
}
184220
}
185-
}
221+
}
Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,60 @@
11
using BrickController2.PlatformServices.Permission;
2-
using static Microsoft.Maui.ApplicationModel.Permissions;
2+
using CoreBluetooth;
3+
using CoreFoundation;
4+
using Microsoft.Maui.ApplicationModel;
5+
using System.Threading.Tasks;
36

47
namespace BrickController2.iOS.PlatformServices.Permission
58
{
6-
internal class BluetoothPermission : BasePlatformPermission, IBluetoothPermission
9+
internal sealed class BluetoothPermission : CBCentralManagerDelegate, IBluetoothPermission
710
{
11+
private readonly object _lock = new();
12+
private CBCentralManager? _permissionManager;
13+
private TaskCompletionSource<PermissionStatus>? _requestCompletionSource;
14+
15+
public Task<PermissionStatus> CheckStatusAsync() => Task.FromResult(GetCurrentStatus());
16+
17+
public Task<PermissionStatus> RequestAsync()
18+
{
19+
var currentStatus = GetCurrentStatus();
20+
if (currentStatus != PermissionStatus.Unknown)
21+
{
22+
return Task.FromResult(currentStatus);
23+
}
24+
25+
lock (_lock)
26+
{
27+
if (_requestCompletionSource is not null)
28+
{
29+
return _requestCompletionSource.Task;
30+
}
31+
32+
_requestCompletionSource = new TaskCompletionSource<PermissionStatus>(TaskCreationOptions.RunContinuationsAsynchronously);
33+
#pragma warning disable CA1422 // Validate platform compatibility
34+
_permissionManager = new CBCentralManager(this, DispatchQueue.MainQueue);
35+
#pragma warning restore CA1422 // Validate platform compatibility
36+
return _requestCompletionSource.Task;
37+
}
38+
}
39+
40+
public override void UpdatedState(CBCentralManager central)
41+
{
42+
var status = GetCurrentStatus();
43+
if (status != PermissionStatus.Unknown)
44+
{
45+
_requestCompletionSource?.TrySetResult(status);
46+
}
47+
}
48+
49+
private static PermissionStatus GetCurrentStatus()
50+
{
51+
return CBManager.Authorization switch
52+
{
53+
CBManagerAuthorization.AllowedAlways => PermissionStatus.Granted,
54+
CBManagerAuthorization.Denied => PermissionStatus.Denied,
55+
CBManagerAuthorization.Restricted => PermissionStatus.Restricted,
56+
_ => PermissionStatus.Unknown
57+
};
58+
}
859
}
9-
}
60+
}

BrickController2/BrickController2/Resources/TranslationResources.de.resx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,45 @@
195195
<data name="BluetoothDevicesWillNOTBeAvailable" xml:space="preserve">
196196
<value>Bluetooth-Geräte werden NICHT verfügbar sein</value>
197197
</data>
198+
<data name="BluetoothAccess" xml:space="preserve">
199+
<value>Bluetooth-Zugriff</value>
200+
</data>
201+
<data name="BluetoothAllowed" xml:space="preserve">
202+
<value>Erlaubt</value>
203+
</data>
204+
<data name="BluetoothDenied" xml:space="preserve">
205+
<value>In den Systemeinstellungen abgelehnt</value>
206+
</data>
207+
<data name="BluetoothNotAllowed" xml:space="preserve">
208+
<value>Nicht erlaubt</value>
209+
</data>
210+
<data name="BluetoothNotRequested" xml:space="preserve">
211+
<value>Noch nicht angefragt</value>
212+
</data>
213+
<data name="BluetoothRestricted" xml:space="preserve">
214+
<value>Eingeschränkt</value>
215+
</data>
198216
<data name="BluetoothPermissionTitle" xml:space="preserve">
199217
<value>BrickController möchte Bluetooth verwenden</value>
200218
</data>
201219
<data name="BluetoothPermissionRequired" xml:space="preserve">
202-
<value>BrickController benötigt Bluetooth, um deine Geräte zu verbinden und zu steuern. Die App funktioniert ohne Bluetooth nicht.</value>
220+
<value>Bluetooth wird nur verwendet, um kompatible Empfänger und Fernbedienungen zu finden, zu verbinden und zu steuern. Andere Teile der App funktionieren auch ohne Bluetooth.</value>
221+
</data>
222+
<data name="BluetoothPermissionDenied" xml:space="preserve">
223+
<value>Der Bluetooth-Zugriff für BrickController ist ausgeschaltet. Du kannst ihn in den Systemeinstellungen aktivieren.</value>
224+
</data>
225+
<data name="BluetoothPermissionDeclined" xml:space="preserve">
226+
<value>Bluetooth wurde nicht aktiviert. Du kannst dies in den BrickController-Einstellungen ändern.</value>
203227
</data>
204228
<data name="Allow" xml:space="preserve">
205229
<value>Erlauben</value>
206230
</data>
231+
<data name="NotNow" xml:space="preserve">
232+
<value>Jetzt nicht</value>
233+
</data>
234+
<data name="OpenSettings" xml:space="preserve">
235+
<value>Einstellungen öffnen</value>
236+
</data>
207237
<data name="Exit" xml:space="preserve">
208238
<value>Beenden</value>
209239
</data>
@@ -237,6 +267,9 @@
237267
<data name="Confirm" xml:space="preserve">
238268
<value>Bestätigen</value>
239269
</data>
270+
<data name="Configure" xml:space="preserve">
271+
<value>Konfigurieren</value>
272+
</data>
240273
<data name="Connecting" xml:space="preserve">
241274
<value>Verbinde...</value>
242275
</data>

0 commit comments

Comments
 (0)