Selaa lähdekoodia

Fixed WebSocket client stream

release/3.x.x
Christian Kratky 7 vuotta sitten
vanhempi
commit
acc269e702
11 muutettua tiedostoa jossa 219 lisäystä ja 138 poistoa
  1. +3
    -1
      Build/MQTTnet.nuspec
  2. +0
    -46
      Frameworks/MQTTnet.AspnetCore/MqttWebSocketServerAdapter.cs
  3. +60
    -0
      Frameworks/MQTTnet.AspnetCore/MqttWebSocketServerChannel.cs
  4. +5
    -1
      Frameworks/MQTTnet.NetStandard/Implementations/MqttWebSocketChannel.cs
  5. +64
    -21
      Frameworks/MQTTnet.NetStandard/Implementations/WebSocketStream.cs
  6. +1
    -1
      Frameworks/MQTTnet.NetStandard/MqttFactory.cs
  7. +70
    -54
      MQTTnet.Core/Client/MqttClient.cs
  8. +2
    -3
      MQTTnet.Core/Packets/MqttPublishPacket.cs
  9. +1
    -1
      MQTTnet.Core/Serializer/MqttPacketReader.cs
  10. +1
    -0
      Tests/MQTTnet.TestApp.UniversalWindows/MainPage.xaml
  11. +12
    -10
      Tests/MQTTnet.TestApp.UniversalWindows/MainPage.xaml.cs

+ 3
- 1
Build/MQTTnet.nuspec Näytä tiedosto

@@ -10,9 +10,11 @@
<iconUrl>https://raw.githubusercontent.com/chkr1011/MQTTnet/master/Images/Logo_128x128.png</iconUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>MQTTnet is a high performance .NET library for MQTT based communication. It provides a MQTT client and a MQTT server (broker).</description>
<releaseNotes>* [Core] Fixed library reference issues for .NET 4.6 (Thanks to @JanEggers).
<releaseNotes>* [Core] Fixed library reference issues for .NET 4.6 and netstandard 2.0 (Thanks to @JanEggers).
* [Core] Several COM exceptions are now wrapped properly resulting in less warnings in the trace.
* [Core] Removed application message payload from trace to reduce trace size and increase performance.
* [Client] Fixed WebSocket sub protocol negotiation for ASP.NET Core 2 servers (Thanks to @JanEggers).
* [Client] Fixed broken connection after 30 seconds then using WebSocket protocol (Thanks to @ChristianRiedl).
* [Server] Client connections are now closed when the server is stopped (Thanks to @zhudanfei).
* [Server] Published messages from the server are now retained (if set) (Thanks to @ChristianRiedl). BREAKING CHANGE!
</releaseNotes>


+ 0
- 46
Frameworks/MQTTnet.AspnetCore/MqttWebSocketServerAdapter.cs Näytä tiedosto

@@ -1,12 +1,8 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using MQTTnet.Core.Adapter;
using MQTTnet.Core.Channel;
using MQTTnet.Core.Server;
using MQTTnet.Implementations;

namespace MQTTnet.AspNetCore
{
@@ -47,47 +43,5 @@ namespace MQTTnet.AspNetCore
{
StopAsync();
}

private class MqttWebSocketServerChannel : IMqttCommunicationChannel, IDisposable
{
private readonly WebSocket _webSocket;

public MqttWebSocketServerChannel(WebSocket webSocket)
{
_webSocket = webSocket ?? throw new ArgumentNullException(nameof(webSocket));

RawReceiveStream = new WebSocketStream(_webSocket);
}

public Stream SendStream => RawReceiveStream;
public Stream ReceiveStream => RawReceiveStream;
public Stream RawReceiveStream { get; }

public Task ConnectAsync()
{
return Task.CompletedTask;
}

public Task DisconnectAsync()
{
RawReceiveStream?.Dispose();

if (_webSocket == null)
{
return Task.CompletedTask;
}

return _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
}

public void Dispose()
{
RawReceiveStream?.Dispose();
SendStream?.Dispose();
ReceiveStream?.Dispose();

_webSocket?.Dispose();
}
}
}
}

+ 60
- 0
Frameworks/MQTTnet.AspnetCore/MqttWebSocketServerChannel.cs Näytä tiedosto

@@ -0,0 +1,60 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using MQTTnet.Core.Channel;
using MQTTnet.Implementations;

namespace MQTTnet.AspNetCore
{
public class MqttWebSocketServerChannel : IMqttCommunicationChannel, IDisposable
{
private WebSocket _webSocket;

public MqttWebSocketServerChannel(WebSocket webSocket)
{
_webSocket = webSocket ?? throw new ArgumentNullException(nameof(webSocket));

SendStream = new WebSocketStream(_webSocket);
ReceiveStream = SendStream;
}

public Stream SendStream { get; private set; }
public Stream ReceiveStream { get; private set; }

public Task ConnectAsync()
{
return Task.CompletedTask;
}

public async Task DisconnectAsync()
{
if (_webSocket == null)
{
return;
}

try
{
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
}
finally
{
Dispose();
}
}

public void Dispose()
{
SendStream?.Dispose();
ReceiveStream?.Dispose();

_webSocket?.Dispose();

SendStream = null;
ReceiveStream = null;
_webSocket = null;
}
}
}

+ 5
- 1
Frameworks/MQTTnet.NetStandard/Implementations/MqttWebSocketChannel.cs Näytä tiedosto

@@ -11,6 +11,10 @@ namespace MQTTnet.Implementations
{
public sealed class MqttWebSocketChannel : IMqttCommunicationChannel, IDisposable
{
// ReSharper disable once MemberCanBePrivate.Global
// ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global
public static int BufferSize { get; set; } = 4096 * 20; // Can be changed for fine tuning by library user.

private readonly MqttClientWebSocketOptions _options;
private ClientWebSocket _webSocket;

@@ -80,7 +84,7 @@ namespace MQTTnet.Implementations
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None).ConfigureAwait(false);
}

_webSocket = null;
Dispose();
}

public void Dispose()


+ 64
- 21
Frameworks/MQTTnet.NetStandard/Implementations/WebSocketStream.cs Näytä tiedosto

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
@@ -10,7 +12,9 @@ namespace MQTTnet.Implementations
public class WebSocketStream : Stream
{
private readonly WebSocket _webSocket;
private readonly byte[] _chunkBuffer = new byte[MqttWebSocketChannel.BufferSize];
private readonly Queue<byte> _buffer = new Queue<byte>(MqttWebSocketChannel.BufferSize);

public WebSocketStream(WebSocket webSocket)
{
_webSocket = webSocket ?? throw new ArgumentNullException(nameof(webSocket));
@@ -34,43 +38,66 @@ namespace MQTTnet.Implementations
{
}

public override Task FlushAsync(CancellationToken cancellationToken)
{
return Task.FromResult(0);
}

public override int Read(byte[] buffer, int offset, int count)
{
return ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
}

public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
var currentOffset = offset;
var targetOffset = offset + count;
while (_webSocket.State == WebSocketState.Open && currentOffset < targetOffset)
var bytesRead = 0;

// Use existing date from buffer.
while (count > 0 && _buffer.Any())
{
var response = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer, currentOffset, count), cancellationToken).ConfigureAwait(false);
currentOffset += response.Count;
count -= response.Count;
if (response.MessageType == WebSocketMessageType.Close)
buffer[offset++] = _buffer.Dequeue();
count--;
bytesRead++;
}

if (count == 0)
{
return bytesRead;
}
while (_webSocket.State == WebSocketState.Open)
{
await FetchChunkAsync(cancellationToken);

while (count > 0 && _buffer.Any())
{
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken).ConfigureAwait(false);
buffer[offset++] = _buffer.Dequeue();
count--;
bytesRead++;
}

if (count == 0)
{
return bytesRead;
}
}

if (_webSocket.State == WebSocketState.Closed)
{
throw new MqttCommunicationException( "connection closed" );
throw new MqttCommunicationException("WebSocket connection closed.");
}

return currentOffset - offset;
return bytesRead;
}

public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
return _webSocket.SendAsync(new ArraySegment<byte>(buffer, offset, count), WebSocketMessageType.Binary, true, cancellationToken);
}

public override int Read(byte[] buffer, int offset, int count)
public override void Write(byte[] buffer, int offset, int count)
{
return ReadAsync(buffer, offset, count).GetAwaiter().GetResult();
WriteAsync(buffer, offset, count).GetAwaiter().GetResult();
}

public override void Write(byte[] buffer, int offset, int count)
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
WriteAsync(buffer, offset, count).GetAwaiter().GetResult();
return _webSocket.SendAsync(new ArraySegment<byte>(buffer, offset, count), WebSocketMessageType.Binary, true, cancellationToken);
}

public override long Seek(long offset, SeekOrigin origin)
@@ -82,5 +109,21 @@ namespace MQTTnet.Implementations
{
throw new NotSupportedException();
}

private async Task FetchChunkAsync(CancellationToken cancellationToken)
{
var response = await _webSocket.ReceiveAsync(new ArraySegment<byte>(_chunkBuffer, 0, _chunkBuffer.Length), cancellationToken).ConfigureAwait(false);

for (var i = 0; i < response.Count; i++)
{
var @byte = _chunkBuffer[i];
_buffer.Enqueue(@byte);
}

if (response.MessageType == WebSocketMessageType.Close)
{
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken).ConfigureAwait(false);
}
}
}
}

+ 1
- 1
Frameworks/MQTTnet.NetStandard/MqttFactory.cs Näytä tiedosto

@@ -23,7 +23,7 @@ namespace MQTTnet

public MqttFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}

public ILoggerFactory GetLoggerFactory()


+ 70
- 54
MQTTnet.Core/Client/MqttClient.cs Näytä tiedosto

@@ -60,14 +60,14 @@ namespace MQTTnet.Core.Client
await _adapter.ConnectAsync(_options.CommunicationTimeout).ConfigureAwait(false);
_logger.LogTrace("Connection with server established.");

await SetupIncomingPacketProcessingAsync().ConfigureAwait(false);
await StartReceivingPacketsAsync(_cancellationTokenSource.Token).ConfigureAwait(false);
var connectResponse = await AuthenticateAsync(options.WillMessage).ConfigureAwait(false);

_logger.LogTrace("MQTT connection with server established.");

if (_options.KeepAlivePeriod != TimeSpan.Zero)
{
StartSendKeepAliveMessages(_cancellationTokenSource.Token);
StartSendingKeepAliveMessages(_cancellationTokenSource.Token);
}

IsConnected = true;
@@ -96,7 +96,6 @@ namespace MQTTnet.Core.Client
finally
{
await DisconnectInternalAsync().ConfigureAwait(false);
_scopeHandle.Dispose();
}
}

@@ -149,36 +148,36 @@ namespace MQTTnet.Core.Client
switch (qosGroup.Key)
{
case MqttQualityOfServiceLevel.AtMostOnce:
{
// No packet identifier is used for QoS 0 [3.3.2.2 Packet Identifier]
await _adapter.SendPacketsAsync(_options.CommunicationTimeout, _cancellationTokenSource.Token, qosPackets).ConfigureAwait(false);
break;
}
case MqttQualityOfServiceLevel.AtLeastOnce:
{
foreach (var publishPacket in qosPackets)
{
publishPacket.PacketIdentifier = GetNewPacketIdentifier();
await SendAndReceiveAsync<MqttPubAckPacket>(publishPacket).ConfigureAwait(false);
// No packet identifier is used for QoS 0 [3.3.2.2 Packet Identifier]
await _adapter.SendPacketsAsync(_options.CommunicationTimeout, _cancellationTokenSource.Token, qosPackets).ConfigureAwait(false);
break;
}
case MqttQualityOfServiceLevel.AtLeastOnce:
{
foreach (var publishPacket in qosPackets)
{
publishPacket.PacketIdentifier = GetNewPacketIdentifier();
await SendAndReceiveAsync<MqttPubAckPacket>(publishPacket).ConfigureAwait(false);
}

break;
}
break;
}
case MqttQualityOfServiceLevel.ExactlyOnce:
{
foreach (var publishPacket in qosPackets)
{
publishPacket.PacketIdentifier = GetNewPacketIdentifier();
var pubRecPacket = await SendAndReceiveAsync<MqttPubRecPacket>(publishPacket).ConfigureAwait(false);
await SendAndReceiveAsync<MqttPubCompPacket>(pubRecPacket.CreateResponse<MqttPubRelPacket>()).ConfigureAwait(false);
foreach (var publishPacket in qosPackets)
{
publishPacket.PacketIdentifier = GetNewPacketIdentifier();
var pubRecPacket = await SendAndReceiveAsync<MqttPubRecPacket>(publishPacket).ConfigureAwait(false);
await SendAndReceiveAsync<MqttPubCompPacket>(pubRecPacket.CreateResponse<MqttPubRelPacket>()).ConfigureAwait(false);
}

break;
}

break;
}
default:
{
throw new InvalidOperationException();
}
{
throw new InvalidOperationException();
}
}
}
}
@@ -191,7 +190,7 @@ namespace MQTTnet.Core.Client
Username = _options.Credentials?.Username,
Password = _options.Credentials?.Password,
CleanSession = _options.CleanSession,
KeepAlivePeriod = (ushort) _options.KeepAlivePeriod.TotalSeconds,
KeepAlivePeriod = (ushort)_options.KeepAlivePeriod.TotalSeconds,
WillMessage = willApplicationMessage
};

@@ -204,24 +203,6 @@ namespace MQTTnet.Core.Client
return response;
}

private async Task SetupIncomingPacketProcessingAsync()
{
_isReceivingPackets = false;

#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
Task.Factory.StartNew(
() => ReceivePackets(_cancellationTokenSource.Token),
_cancellationTokenSource.Token,
TaskCreationOptions.LongRunning,
TaskScheduler.Default).ConfigureAwait(false);
#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed

while (!_isReceivingPackets && _cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMilliseconds(100)).ConfigureAwait(false);
}
}

private void ThrowIfNotConnected()
{
if (!IsConnected) throw new MqttCommunicationException("The client is not connected.");
@@ -234,6 +215,8 @@ namespace MQTTnet.Core.Client

private async Task DisconnectInternalAsync()
{
_scopeHandle?.Dispose();

var clientWasConnected = IsConnected;
IsConnected = false;

@@ -258,6 +241,7 @@ namespace MQTTnet.Core.Client
}
finally
{
_logger.LogInformation("Disconnected.");
Disconnected?.Invoke(this, new MqttClientDisconnectedEventArgs(clientWasConnected));
}
}
@@ -324,7 +308,7 @@ namespace MQTTnet.Core.Client
if (publishPacket.QualityOfServiceLevel == MqttQualityOfServiceLevel.AtLeastOnce)
{
FireApplicationMessageReceivedEvent(publishPacket);
await SendAsync(new MqttPubAckPacket {PacketIdentifier = publishPacket.PacketIdentifier});
await SendAsync(new MqttPubAckPacket { PacketIdentifier = publishPacket.PacketIdentifier });
return;
}

@@ -337,7 +321,7 @@ namespace MQTTnet.Core.Client
}

FireApplicationMessageReceivedEvent(publishPacket);
await SendAsync(new MqttPubRecPacket {PacketIdentifier = publishPacket.PacketIdentifier});
await SendAsync(new MqttPubRecPacket { PacketIdentifier = publishPacket.PacketIdentifier });
return;
}

@@ -363,12 +347,12 @@ namespace MQTTnet.Core.Client
{
var packetAwaiter = _packetDispatcher.WaitForPacketAsync(requestPacket, typeof(TResponsePacket), _options.CommunicationTimeout);
await _adapter.SendPacketsAsync(_options.CommunicationTimeout, _cancellationTokenSource.Token, requestPacket).ConfigureAwait(false);
return (TResponsePacket) await packetAwaiter.ConfigureAwait(false);
return (TResponsePacket)await packetAwaiter.ConfigureAwait(false);
}

private ushort GetNewPacketIdentifier()
{
return (ushort) Interlocked.Increment(ref _latestPacketIdentifier);
return (ushort)Interlocked.Increment(ref _latestPacketIdentifier);
}

private async Task SendKeepAliveMessagesAsync(CancellationToken cancellationToken)
@@ -390,6 +374,12 @@ namespace MQTTnet.Core.Client
}
catch (OperationCanceledException)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}

await DisconnectInternalAsync().ConfigureAwait(false);
}
catch (MqttCommunicationException exception)
{
@@ -412,7 +402,7 @@ namespace MQTTnet.Core.Client
}
}

private async Task ReceivePackets(CancellationToken cancellationToken)
private async Task ReceivePacketsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Start receiving packets.");

@@ -423,6 +413,7 @@ namespace MQTTnet.Core.Client
_isReceivingPackets = true;

var packet = await _adapter.ReceivePacketAsync(TimeSpan.Zero, cancellationToken).ConfigureAwait(false);

if (cancellationToken.IsCancellationRequested)
{
return;
@@ -433,6 +424,12 @@ namespace MQTTnet.Core.Client
}
catch (OperationCanceledException)
{
if (cancellationToken.IsCancellationRequested)
{
return;
}

await DisconnectInternalAsync().ConfigureAwait(false);
}
catch (MqttCommunicationException exception)
{
@@ -458,15 +455,34 @@ namespace MQTTnet.Core.Client
private void StartProcessReceivedPacket(MqttBasePacket packet, CancellationToken cancellationToken)
{
#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
Task.Run(() => ProcessReceivedPacketAsync(packet), cancellationToken).ConfigureAwait(false);
Task.Run(
() => ProcessReceivedPacketAsync(packet),
cancellationToken).ConfigureAwait(false);
#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
}

private async Task StartReceivingPacketsAsync(CancellationToken cancellationToken)
{
_isReceivingPackets = false;

#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
Task.Run(
async () => await ReceivePacketsAsync(cancellationToken),
cancellationToken).ConfigureAwait(false);
#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed

while (!_isReceivingPackets && !cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false);
}
}

private void StartSendKeepAliveMessages(CancellationToken cancellationToken)
private void StartSendingKeepAliveMessages(CancellationToken cancellationToken)
{
#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
Task.Factory.StartNew(() => SendKeepAliveMessagesAsync(cancellationToken), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default)
.ConfigureAwait(false);
Task.Run(
async () => await SendKeepAliveMessagesAsync(cancellationToken),
cancellationToken).ConfigureAwait(false);
#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed
}
}

+ 2
- 3
MQTTnet.Core/Packets/MqttPublishPacket.cs Näytä tiedosto

@@ -1,5 +1,4 @@
using System;
using MQTTnet.Core.Protocol;
using MQTTnet.Core.Protocol;

namespace MQTTnet.Core.Packets
{
@@ -19,7 +18,7 @@ namespace MQTTnet.Core.Packets
{
return nameof(MqttPublishPacket) +
": [Topic=" + Topic + "]" +
" [Payload=" + Convert.ToBase64String(Payload) + "]" +
" [PayloadLength=" + Payload?.Length + "]" +
" [QoSLevel=" + QualityOfServiceLevel + "]" +
" [Dup=" + Dup + "]" +
" [Retain=" + Retain + "]" +


+ 1
- 1
MQTTnet.Core/Serializer/MqttPacketReader.cs Näytä tiedosto

@@ -91,7 +91,7 @@ namespace MQTTnet.Core.Serializer

if (buffer == -1)
{
break;
throw new MqttCommunicationException("Connection closed while reading remaining length data.");
}

encodedByte = (byte)buffer;


+ 1
- 0
Tests/MQTTnet.TestApp.UniversalWindows/MainPage.xaml Näytä tiedosto

@@ -78,6 +78,7 @@
</StackPanel>

<TextBlock>Received messages:</TextBlock>
<CheckBox x:Name="AddReceivedMessagesToList" IsChecked="True">Add received messages to list</CheckBox>
<ListBox MinHeight="50" MaxHeight="250" x:Name="ReceivedMessages" Margin="0,0,0,10">
<ListBox.ItemTemplate>
<DataTemplate>


+ 12
- 10
Tests/MQTTnet.TestApp.UniversalWindows/MainPage.xaml.cs Näytä tiedosto

@@ -30,14 +30,14 @@ namespace MQTTnet.TestApp.UniversalWindows

private async void OnTraceMessagePublished(object sender, MqttNetTraceMessagePublishedEventArgs e)
{
await Trace.Dispatcher.RunAsync(CoreDispatcherPriority.High, () =>
var text = $"[{e.TraceMessage.Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{e.TraceMessage.Level}] [{e.TraceMessage.Source}] [{e.TraceMessage.ThreadId}] [{e.TraceMessage.Message}]{Environment.NewLine}";
if (e.TraceMessage.Exception != null)
{
text += $"{e.TraceMessage.Exception}{Environment.NewLine}";
}
await Trace.Dispatcher.RunAsync(CoreDispatcherPriority.Low, () =>
{
var text = $"[{e.TraceMessage.Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{e.TraceMessage.Level}] [{e.TraceMessage.Source}] [{e.TraceMessage.ThreadId}] [{e.TraceMessage.Message}]{Environment.NewLine}";
if (e.TraceMessage.Exception != null)
{
text += $"{e.TraceMessage.Exception}{Environment.NewLine}";
}

Trace.Text += text;
});
}
@@ -109,11 +109,13 @@ namespace MQTTnet.TestApp.UniversalWindows
{
var item = $"Timestamp: {DateTime.Now:O} | Topic: {eventArgs.ApplicationMessage.Topic} | Payload: {Encoding.UTF8.GetString(eventArgs.ApplicationMessage.Payload)} | QoS: {eventArgs.ApplicationMessage.QualityOfServiceLevel}";

await Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () =>
await Dispatcher.RunAsync(CoreDispatcherPriority.Low, () =>
{
ReceivedMessages.Items.Add(item);
if (AddReceivedMessagesToList.IsChecked == true)
{
ReceivedMessages.Items.Add(item);
}
});

}

private async void Publish(object sender, RoutedEventArgs e)


Ladataan…
Peruuta
Tallenna