From 45259e333275c8690e4db3a2eead944a901049aa Mon Sep 17 00:00:00 2001 From: Julian Baumann Date: Fri, 1 Dec 2023 12:46:12 +0100 Subject: [PATCH] Added iOS BLE advertisement and discovery --- SMTSP.sln | 7 + Sources/SMTSP.BluetoothLowEnergy/BleClient.cs | 10 ++ Sources/SMTSP.BluetoothLowEnergy/BleServer.cs | 15 ++ Sources/SMTSP.BluetoothLowEnergy/Core.cs | 7 + .../Platforms/Android/BleClient.cs | 8 + .../Platforms/Android/BleServer.cs | 12 ++ .../Platforms/Apple/BleClient.cs | 116 +++++++++++++ .../Platforms/Apple/BleServer.cs | 155 ++++++++++++++++++ .../Platforms/Apple/Extensions.cs | 26 +++ .../Platforms/Apple/L2CapStream.cs | 82 +++++++++ .../Platforms/Unsupported/BleClient.cs | 8 + .../Platforms/Unsupported/BleServer.cs | 12 ++ .../SMTSP.BluetoothLowEnergy.csproj | 45 +++++ ...MTSP.BluetoothLowEnergy.csproj.DotSettings | 5 + Sources/SMTSP.Bonjour/SMTSP.Bonjour.csproj | 2 +- ...cryptionHelper.cs => CertificateHelper.cs} | 6 +- .../Backends/TcpCommunicationBackend.cs | 5 - Sources/SMTSP/DeviceDiscovery.cs | 1 + Sources/SMTSP/Discovery/BleAdvertisement.cs | 24 +++ Sources/SMTSP/Discovery/BleDiscovery.cs | 44 ++++- .../SMTSP/Discovery/BonjourAdvertisement.cs | 14 +- Sources/SMTSP/Discovery/BonjourDiscovery.cs | 9 +- Sources/SMTSP/NearbyCommunication.cs | 25 +-- Sources/SMTSP/SMTSP.csproj | 7 +- Tests/SMTSP.Test/CertificateTests.cs | 47 ++++++ Tests/SMTSP.Test/DiscoveryTest.cs | 4 +- Tests/SMTSP.Test/FileTransferTest.cs | 2 +- Tests/SMTSP.Test/SMTSP.Test.csproj | 2 +- 28 files changed, 652 insertions(+), 48 deletions(-) create mode 100644 Sources/SMTSP.BluetoothLowEnergy/BleClient.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/BleServer.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Core.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Platforms/Android/BleClient.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Platforms/Android/BleServer.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/BleClient.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/BleServer.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/Extensions.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/L2CapStream.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Platforms/Unsupported/BleClient.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/Platforms/Unsupported/BleServer.cs create mode 100644 Sources/SMTSP.BluetoothLowEnergy/SMTSP.BluetoothLowEnergy.csproj create mode 100644 Sources/SMTSP.BluetoothLowEnergy/SMTSP.BluetoothLowEnergy.csproj.DotSettings rename Sources/SMTSP/{EncryptionHelper.cs => CertificateHelper.cs} (74%) create mode 100644 Sources/SMTSP/Discovery/BleAdvertisement.cs create mode 100644 Tests/SMTSP.Test/CertificateTests.cs diff --git a/SMTSP.sln b/SMTSP.sln index 84e1eac..716fe72 100644 --- a/SMTSP.sln +++ b/SMTSP.sln @@ -10,6 +10,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sources", "Sources", "{AF40 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{7010AC18-820A-4234-BBFF-82B22109B408}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SMTSP.BluetoothLowEnergy", "Sources\SMTSP.BluetoothLowEnergy\SMTSP.BluetoothLowEnergy.csproj", "{666E196C-D827-4B7F-88C8-EE00108BB228}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -28,10 +30,15 @@ Global {E9FD217B-3255-4495-BD3B-D50B90D9EBAB}.Debug|Any CPU.Build.0 = Debug|Any CPU {E9FD217B-3255-4495-BD3B-D50B90D9EBAB}.Release|Any CPU.ActiveCfg = Release|Any CPU {E9FD217B-3255-4495-BD3B-D50B90D9EBAB}.Release|Any CPU.Build.0 = Release|Any CPU + {666E196C-D827-4B7F-88C8-EE00108BB228}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {666E196C-D827-4B7F-88C8-EE00108BB228}.Debug|Any CPU.Build.0 = Debug|Any CPU + {666E196C-D827-4B7F-88C8-EE00108BB228}.Release|Any CPU.ActiveCfg = Release|Any CPU + {666E196C-D827-4B7F-88C8-EE00108BB228}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {E9FD217B-3255-4495-BD3B-D50B90D9EBAB} = {AF403CDB-D1CB-44DD-81C7-B5B822941D43} {FC4F2C45-E24B-45DD-831E-648EB107847A} = {AF403CDB-D1CB-44DD-81C7-B5B822941D43} {13A8E9DB-1627-4306-9E75-21B7118C5158} = {7010AC18-820A-4234-BBFF-82B22109B408} + {666E196C-D827-4B7F-88C8-EE00108BB228} = {AF403CDB-D1CB-44DD-81C7-B5B822941D43} EndGlobalSection EndGlobal diff --git a/Sources/SMTSP.BluetoothLowEnergy/BleClient.cs b/Sources/SMTSP.BluetoothLowEnergy/BleClient.cs new file mode 100644 index 0000000..f6a2cf6 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/BleClient.cs @@ -0,0 +1,10 @@ +namespace SMTSP.BluetoothLowEnergy; + +public partial class BleClient(byte[] deviceData) +{ + public bool IsResponding { get; private set; } + + public partial Task RequestAccess(); + public partial void StartRespondingToDiscoveryBroadcasts(); + public partial void StopRespondingToDiscoveryBroadcasts(); +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/BleServer.cs b/Sources/SMTSP.BluetoothLowEnergy/BleServer.cs new file mode 100644 index 0000000..0e02748 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/BleServer.cs @@ -0,0 +1,15 @@ +namespace SMTSP.BluetoothLowEnergy; + +public partial class BleServer +{ + public event EventHandler PeripheralDataDiscovered = delegate { }; + public event EventHandler ClientConnected = delegate { }; + + public partial Task RequestAccess(); + + public partial Task StartServer(); + public partial void StopServer(); + + public partial void StartDiscovering(); + public partial void StopDiscovering(); +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Core.cs b/Sources/SMTSP.BluetoothLowEnergy/Core.cs new file mode 100644 index 0000000..746acc5 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Core.cs @@ -0,0 +1,7 @@ +namespace SMTSP.BluetoothLowEnergy; + +public static class Core +{ + public const string ServiceUuid = "68D60EB2-8AAA-4D72-8851-BD6D64E169B7"; + public const string CharacteristicUuid = "0BEBF3FE-9A5E-4ED1-8157-76281B3F0DA5"; +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Platforms/Android/BleClient.cs b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Android/BleClient.cs new file mode 100644 index 0000000..6c58069 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Android/BleClient.cs @@ -0,0 +1,8 @@ +namespace SMTSP.BluetoothLowEnergy; + +public partial class BleClient +{ + public partial Task RequestAccess() => throw new PlatformNotSupportedException(); + public partial void StartRespondingToDiscoveryBroadcasts() => throw new PlatformNotSupportedException(); + public partial void StopRespondingToDiscoveryBroadcasts() => throw new PlatformNotSupportedException(); +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Platforms/Android/BleServer.cs b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Android/BleServer.cs new file mode 100644 index 0000000..8582639 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Android/BleServer.cs @@ -0,0 +1,12 @@ +namespace SMTSP.BluetoothLowEnergy; + +public partial class BleServer +{ + public partial Task RequestAccess() => throw new PlatformNotSupportedException(); + + public partial Task StartServer() => throw new PlatformNotSupportedException(); + public partial void StopServer() => throw new PlatformNotSupportedException(); + + public partial void StartDiscovering() => throw new PlatformNotSupportedException(); + public partial void StopDiscovering() => throw new PlatformNotSupportedException(); +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/BleClient.cs b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/BleClient.cs new file mode 100644 index 0000000..8c7eda8 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/BleClient.cs @@ -0,0 +1,116 @@ +using CoreBluetooth; + +namespace SMTSP.BluetoothLowEnergy; + +public partial class BleClient : CBCentralManagerDelegate +{ + private CBCentralManager? _manager; + private readonly TaskCompletionSource _stateTaskCompletionSource = new(); + private readonly NSData _deviceData = NSData.FromArray(deviceData); + private readonly CBUUID _nativeServiceUuid = CBUUID.FromString(Core.ServiceUuid); + private readonly CBUUID _nativeCharacteristicUuid = CBUUID.FromString(Core.CharacteristicUuid); + + public CBCentralManager Manager + { + get + { + if (_manager == null) + { + _manager = new CBCentralManager(this, null); + _manager.Delegate = this; + } + + return _manager; + } + } + + public partial Task RequestAccess() + { + Extensions.EnsureAllowed(); + + if (Manager.State != CBManagerState.Unknown) + { + return Task.FromResult(true); + } + + _ = Manager.State; + + return _stateTaskCompletionSource.Task; + } + + public partial void StartRespondingToDiscoveryBroadcasts() + { + if (Manager.IsScanning) + { + return; + } + + Manager.ScanForPeripherals([_nativeServiceUuid], new PeripheralScanningOptions + { + AllowDuplicatesKey = true + }); + + IsResponding = true; + } + + public partial void StopRespondingToDiscoveryBroadcasts() + { + IsResponding = false; + Manager.StopScan(); + } + + public override void UpdatedState(CBCentralManager central) + { + _stateTaskCompletionSource.TrySetResult(Manager.State == CBManagerState.PoweredOn); + } + + public override void DiscoveredPeripheral(CBCentralManager central, CBPeripheral peripheral, NSDictionary advertisementData, NSNumber rssi) + { + if (!IsResponding) + { + return; + } + + Manager.ConnectPeripheral(peripheral); + } + + private void PeripheralOnDiscoveredService(object? sender, NSErrorEventArgs e) + { + if (sender is not CBPeripheral cbPeripheral) { return; } + + var service = cbPeripheral.Services?.FirstOrDefault( + element => element.UUID == _nativeServiceUuid + ); + + if (service == null) + { + return; + } + + cbPeripheral.DiscoverCharacteristics([_nativeCharacteristicUuid], service); + } + + private void PeripheralOnDiscoveredCharacteristics(object? sender, CBServiceEventArgs args) + { + if (IsResponding) + { + var characteristic = args.Service.Characteristics?.FirstOrDefault(element => + element.UUID == _nativeCharacteristicUuid); + + if (characteristic == null) + { + return; + } + + args.Service.Peripheral?.WriteValue(_deviceData, characteristic, CBCharacteristicWriteType.WithoutResponse); + } + } + + public override void ConnectedPeripheral(CBCentralManager central, CBPeripheral nativePeripheral) + { + nativePeripheral.DiscoveredService += PeripheralOnDiscoveredService; + nativePeripheral.DiscoveredCharacteristics += PeripheralOnDiscoveredCharacteristics; + + nativePeripheral.DiscoverServices([_nativeServiceUuid]); + } +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/BleServer.cs b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/BleServer.cs new file mode 100644 index 0000000..9b37fbe --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/BleServer.cs @@ -0,0 +1,155 @@ +using CoreBluetooth; + +namespace SMTSP.BluetoothLowEnergy; + +public partial class BleServer +{ + private CBPeripheralManager? _manager; + protected CBPeripheralManager Manager + { + get + { + // var options = new NSDictionary( + // CBPeripheralManager.OptionRestoreIdentifierKey, "com.julian-baumann.smtsp" + // ); + + // _manager ??= new CBPeripheralManager(peripheralDelegate: null, queue: null, options: options); + _manager ??= new CBPeripheralManager(); + return _manager; + } + } + + private ushort _psm; + + public partial Task RequestAccess() + { + Extensions.EnsureAllowed(); + + if (Manager.State != CBManagerState.Unknown) + { + return Task.FromResult(true); + } + + var result = new TaskCompletionSource(); + + Manager.StateUpdated += (_, _) => + { + result.SetResult(Manager.State == CBManagerState.PoweredOn); + }; + + _ = Manager.State; + + return result.Task; + } + + private async Task PublishL2Cap(bool secure) + { + var taskCompletionSource = new TaskCompletionSource(); + + var handler = new EventHandler((_, args) => + { + if (args.Error == null) + { + taskCompletionSource.TrySetResult(args.Psm); + } + else + { + taskCompletionSource.TrySetException(new InvalidOperationException(args.Error.Description)); + } + }); + + Manager.DidPublishL2CapChannel += handler; + + try + { + Manager.PublishL2CapChannel(secure); + return await taskCompletionSource.Task.ConfigureAwait(false); + } + finally + { + Manager.DidPublishL2CapChannel -= handler; + } + } + + private void ManagerOnDidOpenL2CapChannel(object? sender, CBPeripheralManagerOpenL2CapChannelEventArgs args) + { + //args.Channel.InputStream.Status == NSStreamStatus.Open + var channel = args.Channel!; + channel.InputStream.Open(); + channel.OutputStream.Open(); + + ClientConnected.Invoke(this, new L2CapStream(channel.OutputStream, channel.InputStream)); + } + + private void AdvertiseService() + { + var service = new CBMutableService(CBUUID.FromString(Core.ServiceUuid), true); + + var characteristic = new CBMutableCharacteristic( + uuid: CBUUID.FromString(Core.CharacteristicUuid), + properties: CBCharacteristicProperties.Read | CBCharacteristicProperties.Write, + value: null, + permissions: CBAttributePermissions.Readable | CBAttributePermissions.Writeable + ); + + service.Characteristics = [characteristic]; + + Manager.WriteRequestsReceived += ManagerOnWriteRequestsReceived; + + Manager.AddService(service); + Manager.StartAdvertising(new StartAdvertisingOptions + { + ServicesUUID = [CBUUID.FromString(Core.ServiceUuid)] + }); + } + + private void ManagerOnWriteRequestsReceived(object? sender, CBATTRequestsEventArgs args) + { + foreach (var request in args.Requests) + { + var value = request.Value; + + if (value == null) + { + return; + } + + var valueAsByteArray = new byte[value.Length]; + System.Runtime.InteropServices.Marshal.Copy(value.Bytes, valueAsByteArray, 0, Convert.ToInt32(value.Length)); + + PeripheralDataDiscovered.Invoke(this, valueAsByteArray); + } + } + + public partial async Task StartServer() + { + Manager.DidOpenL2CapChannel += ManagerOnDidOpenL2CapChannel; + _psm = await PublishL2Cap(false); + + return _psm; + } + + public partial void StartDiscovering() + { + if (_psm <= 0) + { + throw new InvalidOperationException("PSM unknown, did you forget to call StartServer()?"); + } + + AdvertiseService(); + } + + public partial void StopDiscovering() + { + Manager.StopAdvertising(); + Manager.RemoveAllServices(); + } + + public partial void StopServer() + { + if (_psm <= 0) { return; } + + Manager.UnpublishL2CapChannel(_psm); + Manager.DidOpenL2CapChannel -= ManagerOnDidOpenL2CapChannel; + } +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/Extensions.cs b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/Extensions.cs new file mode 100644 index 0000000..ced4d20 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/Extensions.cs @@ -0,0 +1,26 @@ +namespace SMTSP.BluetoothLowEnergy; + +public static class Extensions +{ + public static bool HasPlistValue(string key) + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + return NSBundle.MainBundle.ObjectForInfoDictionary(key) != null; + } + + public static void EnsureAllowed() + { + var hasPeripheralUsage = HasPlistValue("NSBluetoothPeripheralUsageDescription"); + var hasAlwaysUsage = HasPlistValue("NSBluetoothAlwaysUsageDescription"); + + if (!hasPeripheralUsage) + { + throw new UnauthorizedAccessException("\"NSBluetoothPeripheralUsageDescription\" is not set."); + } + + if (!hasAlwaysUsage) + { + throw new UnauthorizedAccessException("\"NSBluetoothAlwaysUsageDescription\" is not set."); + } + } +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/L2CapStream.cs b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/L2CapStream.cs new file mode 100644 index 0000000..2dda780 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Apple/L2CapStream.cs @@ -0,0 +1,82 @@ +namespace SMTSP.BluetoothLowEnergy; + +public class L2CapStream(NSOutputStream outputStream, NSInputStream inputStream) : Stream +{ + private readonly List _buffer = []; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + Write(_buffer.ToArray(), 0, _buffer.Count); + _buffer.Clear(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return inputStream.Read(buffer, offset, new UIntPtr((ulong)count)).ToInt32(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + var stillToSend = new byte[buffer.Length - offset]; + buffer.CopyTo(stillToSend, offset); + + while (count > 0) + { + if (!outputStream.HasSpaceAvailable()) + { + continue; + } + + var bytesWritten = outputStream.Write(buffer, offset, (uint)count); + + if (bytesWritten == -1) + { + throw new InvalidOperationException("Write error: -1 returned"); + } + + if (bytesWritten > 0) + { + count -= (int)bytesWritten; + + if (0 == count) + { + break; + } + + var temp = new List(); + + for (var i = bytesWritten; i < stillToSend.Length; i++) + { + temp.Add(stillToSend[i]); + } + + stillToSend = temp.ToArray(); + } + } + } + + public override void WriteByte(byte value) + { + _buffer.Add(value); + } +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Platforms/Unsupported/BleClient.cs b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Unsupported/BleClient.cs new file mode 100644 index 0000000..6c58069 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Unsupported/BleClient.cs @@ -0,0 +1,8 @@ +namespace SMTSP.BluetoothLowEnergy; + +public partial class BleClient +{ + public partial Task RequestAccess() => throw new PlatformNotSupportedException(); + public partial void StartRespondingToDiscoveryBroadcasts() => throw new PlatformNotSupportedException(); + public partial void StopRespondingToDiscoveryBroadcasts() => throw new PlatformNotSupportedException(); +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/Platforms/Unsupported/BleServer.cs b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Unsupported/BleServer.cs new file mode 100644 index 0000000..8582639 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/Platforms/Unsupported/BleServer.cs @@ -0,0 +1,12 @@ +namespace SMTSP.BluetoothLowEnergy; + +public partial class BleServer +{ + public partial Task RequestAccess() => throw new PlatformNotSupportedException(); + + public partial Task StartServer() => throw new PlatformNotSupportedException(); + public partial void StopServer() => throw new PlatformNotSupportedException(); + + public partial void StartDiscovering() => throw new PlatformNotSupportedException(); + public partial void StopDiscovering() => throw new PlatformNotSupportedException(); +} diff --git a/Sources/SMTSP.BluetoothLowEnergy/SMTSP.BluetoothLowEnergy.csproj b/Sources/SMTSP.BluetoothLowEnergy/SMTSP.BluetoothLowEnergy.csproj new file mode 100644 index 0000000..fd69495 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/SMTSP.BluetoothLowEnergy.csproj @@ -0,0 +1,45 @@ + + + net8.0;net8.0-android;net8.0-ios;net8.0-maccatalyst + $(TargetFrameworks);net8.0-windows10.0.19041.0 + enable + enable + false + + 11.0 + 13.1 + 21.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/SMTSP.BluetoothLowEnergy/SMTSP.BluetoothLowEnergy.csproj.DotSettings b/Sources/SMTSP.BluetoothLowEnergy/SMTSP.BluetoothLowEnergy.csproj.DotSettings new file mode 100644 index 0000000..4f55c34 --- /dev/null +++ b/Sources/SMTSP.BluetoothLowEnergy/SMTSP.BluetoothLowEnergy.csproj.DotSettings @@ -0,0 +1,5 @@ + + True + True + True + True \ No newline at end of file diff --git a/Sources/SMTSP.Bonjour/SMTSP.Bonjour.csproj b/Sources/SMTSP.Bonjour/SMTSP.Bonjour.csproj index 4c360d5..f3049ee 100644 --- a/Sources/SMTSP.Bonjour/SMTSP.Bonjour.csproj +++ b/Sources/SMTSP.Bonjour/SMTSP.Bonjour.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 true 1.3.0 Arkane Systems diff --git a/Sources/SMTSP/EncryptionHelper.cs b/Sources/SMTSP/CertificateHelper.cs similarity index 74% rename from Sources/SMTSP/EncryptionHelper.cs rename to Sources/SMTSP/CertificateHelper.cs index 2f59e94..76dd29f 100644 --- a/Sources/SMTSP/EncryptionHelper.cs +++ b/Sources/SMTSP/CertificateHelper.cs @@ -4,7 +4,7 @@ namespace SMTSP; -public static class EncryptionHelper +public static class CertificateHelper { public static X509Certificate2 GenerateSelfSignedCertificate() { @@ -21,8 +21,6 @@ public static X509Certificate2 GenerateSelfSignedCertificate() certificateRequest.CertificateExtensions.Add(sanBuilder.Build()); var certificate = certificateRequest.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(10)); - // It seems like we have to provide a password, or else the private key won't be exported. - const string exportPassword = "notnull"; - return new X509Certificate2(certificate.Export(X509ContentType.Pfx, exportPassword), exportPassword); + return certificate; } } diff --git a/Sources/SMTSP/Communication/Backends/TcpCommunicationBackend.cs b/Sources/SMTSP/Communication/Backends/TcpCommunicationBackend.cs index c69250a..e4f97b2 100644 --- a/Sources/SMTSP/Communication/Backends/TcpCommunicationBackend.cs +++ b/Sources/SMTSP/Communication/Backends/TcpCommunicationBackend.cs @@ -97,12 +97,7 @@ public Task Start(Device myDevice, X509Certificate2 certificate) public async Task<(SslStream, IDisposable)> ConnectToDevice(Device receiver) { - // var ipAddress = Dns.GetHostEntry(receiver.TcpConnectionInfo.Hostname) - // .AddressList - // .First(addr => addr.AddressFamily == AddressFamily.InterNetwork); - var client = new TcpClient(); - // client.Connect(ipAddress, Convert.ToInt32(receiver.TcpConnectionInfo.Port)); await client.ConnectAsync(receiver.TcpConnectionInfo.Hostname, Convert.ToInt32(receiver.TcpConnectionInfo.Port), _cancellationToken); var sslStream = new SslStream( diff --git a/Sources/SMTSP/DeviceDiscovery.cs b/Sources/SMTSP/DeviceDiscovery.cs index 523f43e..3fae07e 100644 --- a/Sources/SMTSP/DeviceDiscovery.cs +++ b/Sources/SMTSP/DeviceDiscovery.cs @@ -7,6 +7,7 @@ public class DeviceDiscovery { private readonly Device _myDevice; private readonly BonjourDiscovery _innerDiscoveryService; + private readonly BleDiscovery _bleDiscovery = new(); public ObservableCollection DiscoveredDevices { get; } diff --git a/Sources/SMTSP/Discovery/BleAdvertisement.cs b/Sources/SMTSP/Discovery/BleAdvertisement.cs new file mode 100644 index 0000000..e1c34c8 --- /dev/null +++ b/Sources/SMTSP/Discovery/BleAdvertisement.cs @@ -0,0 +1,24 @@ +using Google.Protobuf; +using SMTSP.BluetoothLowEnergy; + +namespace SMTSP.Discovery; + +public class BleAdvertisement(Device myDevice) +{ + private readonly BleClient _bleClient = new(myDevice.ToByteArray()); + + public Task RequestAccess() + { + return _bleClient.RequestAccess(); + } + + public void StartAdvertising() + { + _bleClient.StartRespondingToDiscoveryBroadcasts(); + } + + public void StopAdvertising() + { + _bleClient.StopRespondingToDiscoveryBroadcasts(); + } +} diff --git a/Sources/SMTSP/Discovery/BleDiscovery.cs b/Sources/SMTSP/Discovery/BleDiscovery.cs index e917226..738f981 100644 --- a/Sources/SMTSP/Discovery/BleDiscovery.cs +++ b/Sources/SMTSP/Discovery/BleDiscovery.cs @@ -1,12 +1,50 @@ -using Plugin.BLE; +using System.Collections.ObjectModel; +using SMTSP.BluetoothLowEnergy; namespace SMTSP.Discovery; public class BleDiscovery { + private readonly BleServer _bleServer = new(); + + public ObservableCollection DiscoveredDevices { get; } = []; + public BleDiscovery() { - var ble = CrossBluetoothLE.Current; - var adapter = CrossBluetoothLE.Current.Adapter; + _bleServer.PeripheralDataDiscovered += OnPeripheralDataDiscovered; + } + + public Task RequestAccess() + { + return _bleServer.RequestAccess(); + } + + public void Browse() + { + _bleServer.StartDiscovering(); + } + + private void OnPeripheralDataDiscovered(object? sender, byte[] rawDeviceData) + { + var device = Device.Parser.ParseFrom(rawDeviceData); + AddOrReplaceDevice(device); + } + + private void AddOrReplaceDevice(Device device) + { + lock (DiscoveredDevices) + { + var existingDevice = DiscoveredDevices.FirstOrDefault(element => element.Id == device.Id); + + if (existingDevice == null) + { + DiscoveredDevices.Add(device); + } + else + { + var index = DiscoveredDevices.IndexOf(existingDevice); + DiscoveredDevices[index] = device; + } + } } } diff --git a/Sources/SMTSP/Discovery/BonjourAdvertisement.cs b/Sources/SMTSP/Discovery/BonjourAdvertisement.cs index ee8072b..50ed1e7 100644 --- a/Sources/SMTSP/Discovery/BonjourAdvertisement.cs +++ b/Sources/SMTSP/Discovery/BonjourAdvertisement.cs @@ -3,27 +3,21 @@ namespace SMTSP.Discovery; -public class BonjourAdvertisement +public class BonjourAdvertisement(Device device) { - private readonly Device _myDevice; private RegisterService? _service; - public BonjourAdvertisement(Device device) - { - _myDevice = device; - } - public void Register(ushort port) { _service = new RegisterService(); - _service.Name = _myDevice.Id; + _service.Name = device.Id; _service.RegType = BonjourDiscovery.ServiceName; _service.ReplyDomain = "local."; _service.UPort = port; var txtRecord = new TxtRecord(); - txtRecord.Add(TxtProperties.Name, _myDevice.Name); - txtRecord.Add(TxtProperties.Type, _myDevice.Type.ToString()); + txtRecord.Add(TxtProperties.Name, device.Name); + txtRecord.Add(TxtProperties.Type, device.Type.ToString()); txtRecord.Add(TxtProperties.Version, Config.ProtocolVersion.ToString()); _service.TxtRecord = txtRecord; diff --git a/Sources/SMTSP/Discovery/BonjourDiscovery.cs b/Sources/SMTSP/Discovery/BonjourDiscovery.cs index 9eef870..cbdf9c5 100644 --- a/Sources/SMTSP/Discovery/BonjourDiscovery.cs +++ b/Sources/SMTSP/Discovery/BonjourDiscovery.cs @@ -16,18 +16,13 @@ internal struct TxtProperties /// This uses the native underlying bonjour libraries to advertise/discover mDNS services. /// Uses Avahi or Bonjour/mDNSResponder. /// -internal class BonjourDiscovery +internal class BonjourDiscovery(Device device) { public const string ServiceName = "_smtsp._tcp"; - private readonly Device _myDevice; + private readonly Device _myDevice = device; public ObservableCollection DiscoveredDevices { get; } = new(); - public BonjourDiscovery(Device device) - { - _myDevice = device; - } - public void Browse() { var browser = new ServiceBrowser(); diff --git a/Sources/SMTSP/NearbyCommunication.cs b/Sources/SMTSP/NearbyCommunication.cs index 0b888b4..e46f403 100644 --- a/Sources/SMTSP/NearbyCommunication.cs +++ b/Sources/SMTSP/NearbyCommunication.cs @@ -1,5 +1,6 @@ using System.Net.Security; using System.Security.Cryptography.X509Certificates; +using SMTSP.BluetoothLowEnergy; using SMTSP.Communication; using SMTSP.Communication.TransferTypes; using SMTSP.Core; @@ -12,7 +13,8 @@ public class NearbyCommunication { private readonly Device _device; private readonly X509Certificate2 _certificate; - private readonly BonjourAdvertisement _bonjourAdvertisement; + // private readonly BonjourAdvertisement _bonjourAdvertisement; + private readonly BleAdvertisement _bleAdvertisement; public event EventHandler OnConnectionRequest = delegate { }; @@ -20,7 +22,8 @@ public NearbyCommunication(Device myDevice, X509Certificate2 certificate) { _device = myDevice; _certificate = certificate; - _bonjourAdvertisement = new BonjourAdvertisement(_device); + // _bonjourAdvertisement = new BonjourAdvertisement(_device); + _bleAdvertisement = new BleAdvertisement(_device); } /// @@ -43,19 +46,21 @@ public async Task StartReceiving() } } - public void AdvertiseDevice() + public void StartAdvertising() { - if (_device.TcpConnectionInfo == null || _device.TcpConnectionInfo.Port == 0) - { - throw new NullReferenceException("TCP Port is unknown. Did you forget to start the NearbyCommunication server?"); - } + _bleAdvertisement.StartAdvertising(); + // if (_device.TcpConnectionInfo == null || _device.TcpConnectionInfo.Port == 0) + // { + // throw new NullReferenceException("TCP Port is unknown. Did you forget to start the NearbyCommunication server?"); + // } - _bonjourAdvertisement.Register((ushort) _device.TcpConnectionInfo.Port); + // _bonjourAdvertisement.Register((ushort) _device.TcpConnectionInfo.Port); } - public void UnlistDevice() + public void StopAdvertising() { - _bonjourAdvertisement.Unregister(); + _bleAdvertisement.StopAdvertising(); + // _bonjourAdvertisement.Unregister(); } // public async Task SendFiles(Device recipient, ZipArchive files, IProgress? progress = null, CancellationToken cancellationToken = default) diff --git a/Sources/SMTSP/SMTSP.csproj b/Sources/SMTSP/SMTSP.csproj index d848bcf..615bc95 100644 --- a/Sources/SMTSP/SMTSP.csproj +++ b/Sources/SMTSP/SMTSP.csproj @@ -1,19 +1,18 @@ - net7.0 + net8.0;net8.0-ios enable enable + - - - + diff --git a/Tests/SMTSP.Test/CertificateTests.cs b/Tests/SMTSP.Test/CertificateTests.cs new file mode 100644 index 0000000..e66f0a3 --- /dev/null +++ b/Tests/SMTSP.Test/CertificateTests.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography.X509Certificates; +using NUnit.Framework; + +namespace SMTSP.Test; + +public class CertificateTests +{ + [Test] + public void TestCertificatePersistence() + { + // Store the certificate + var certificate = CertificateHelper.GenerateSelfSignedCertificate(); + var thumbprint = certificate.Thumbprint; + + Assert.IsNotEmpty(thumbprint); + Assert.IsTrue(certificate.HasPrivateKey); + + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.MaxAllowed); + + store.Add(certificate); + store.Close(); + + // Get the certificate from storage + var importedCertificate = RetrieveCertificate(thumbprint); + + Assert.That(importedCertificate.HasPrivateKey, Is.True); + Assert.AreEqual(importedCertificate.Thumbprint, certificate.Thumbprint); + Assert.AreEqual(importedCertificate.SubjectName.Name, certificate.SubjectName.Name); + Assert.That(certificate.GetECDsaPrivateKey()?.ExportECPrivateKey(), + Is.EqualTo(expected: importedCertificate.GetECDsaPrivateKey()?.ExportECPrivateKey())); + } + + private static X509Certificate2 RetrieveCertificate(string thumbprint) + { + using var store = new X509Store(StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + var collection = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false); + + Assert.That(collection, Is.Not.Empty); + + var certificate = collection[0]; + Assert.That(certificate, Is.Not.Null); + + return certificate; + } +} diff --git a/Tests/SMTSP.Test/DiscoveryTest.cs b/Tests/SMTSP.Test/DiscoveryTest.cs index b46d0da..cb3ea35 100644 --- a/Tests/SMTSP.Test/DiscoveryTest.cs +++ b/Tests/SMTSP.Test/DiscoveryTest.cs @@ -8,7 +8,7 @@ namespace SMTSP.Test; public class DiscoveryTests { private const string AdvertisedDeviceId = "8F596F84-57AD-4D97-817D-D5ADECD2A9FF"; - private readonly X509Certificate2 _certificate = EncryptionHelper.GenerateSelfSignedCertificate(); + private readonly X509Certificate2 _certificate = CertificateHelper.GenerateSelfSignedCertificate(); [SetUp] public void Setup() @@ -25,7 +25,7 @@ public void Setup() } }, _certificate); - discovery.AdvertiseDevice(); + discovery.StartAdvertising(); } [Test] diff --git a/Tests/SMTSP.Test/FileTransferTest.cs b/Tests/SMTSP.Test/FileTransferTest.cs index 7a82abb..c934f06 100644 --- a/Tests/SMTSP.Test/FileTransferTest.cs +++ b/Tests/SMTSP.Test/FileTransferTest.cs @@ -12,7 +12,7 @@ public class FileTransferTest private const string ReceivedFilePath = "./ReceivedFile.txt"; private const string FileContent = "Hello, World\n"; - private readonly X509Certificate2 _certificate = EncryptionHelper.GenerateSelfSignedCertificate(); + private readonly X509Certificate2 _certificate = CertificateHelper.GenerateSelfSignedCertificate(); private static readonly Device ServerDevice = new() { diff --git a/Tests/SMTSP.Test/SMTSP.Test.csproj b/Tests/SMTSP.Test/SMTSP.Test.csproj index e11b56b..1f2b1b1 100644 --- a/Tests/SMTSP.Test/SMTSP.Test.csproj +++ b/Tests/SMTSP.Test/SMTSP.Test.csproj @@ -1,7 +1,7 @@ - net7.0 + net8.0 enable enable