For my Windows Phone 8 game “Pull the Rope” I wrote a utility class to make pairing phones and obtaining a two-way communication channel a little bit easier. I already dabbled into this while developing the game, but now I feel it’s time to show a more comprehensive solution.
I re-use the NavigationEventArgsExtensions and the NavigationMessage from my previous article “Handling Windows Phone 8 NFC startup events using the MVVMLight” without describing them further – I suggest you read this article as a preface to this one if you haven’t done so before. The utility class I describe in this article handles the pairing via tap-to-connect and provides – when that’s done – a method for sending a message and an event for receiving a message. I have peppered the code with debug statements, so can you nicely see in your output windows what’s exactly happening inside of the class when you are debugging on the phone.
Messages are sent and received as strings, therefore the message argument class is not very complex either:
using System; namespace Wp7nl.Devices { public class ReceivedMessageEventArgs : EventArgs { public string Message { get; set; } } }The start of the TtcSocketHelper (‘Tap-To-Connect’) class, as I have called it, is as follows:
using System; using System.Diagnostics; using System.Threading.Tasks; using GalaSoft.MvvmLight.Messaging; using Windows.Foundation; using Windows.Networking.Proximity; using Windows.Networking.Sockets; using Windows.Storage.Streams; namespace Wp7nl.Devices { public class TtcSocketHelper { public TtcSocketHelper() { Messenger.Default.Register<NavigationMessage>(this, ProcessNavigationMessage); } public virtual void Start() { PeerFinder.TriggeredConnectionStateChanged += PeerFinderTriggeredConnectionStateChanged; PeerFinder.AllowBluetooth = true; PeerFinder.Start(); } private void ProcessNavigationMessage(NavigationMessage message) { Debug.WriteLine("TtsHelper.ProcessNavigationMessage " + message.NavigationEvent.Uri); if (message.IsStartedByNfcRequest) { Start(); } } } }The constructor subscribes this helper class to a message that will need to be fired when the application navigates to it's main page. If it detects the app is started by an NfcRequest - i.e. a tap-to-connect, the PeerFinder is immediately started to allow for the further build-up of the connection. I already described this technique in the article I already mentioned. The method Start can also be called by an the application, for instance the press of a “connect” button. Note that I only allow Bluetooth connections – this is because this class comes from my game, which does not need the highest possible speed, but the lowest possible latency. Ironically Wi-Fi seems to have a little more latency than Bluetooth.
The next part is the handling of the connection process itself:
private void PeerFinderTriggeredConnectionStateChanged(object sender, TriggeredConnectionStateChangedEventArgs args) { switch (args.State) { case TriggeredConnectState.Completed: FireConnectionStatusChanged(args); socket = args.Socket; StartListeningForMessages(); PeerFinder.Stop(); break; default: FireConnectionStatusChanged(args); break; } } private void FireConnectionStatusChanged(TriggeredConnectionStateChangedEventArgs args) { Debug.WriteLine("TtsHelper: " + args); if (ConnectionStatusChanged != null) { ConnectionStatusChanged(this, args ); } } public event TypedEventHandler<object, TriggeredConnectionStateChangedEventArgs> ConnectionStatusChanged; private StreamSocket socket;
This is kind of nicked from the Bluetooth app to app sample at MSDN, although I made it a bit simpler: only on TriggeredConnectState.Completed I need to do something, i.e. obtain a socket. For the rest of the events, I just pass them to the outside world in case it’s interested.
Next is the part that initiates and performs the actual listening for messages, once the socket is obtained:
private async void StartListeningForMessages() { if( socket != null ) { if (!listening) { listening = true; while (listening) { var message = await GetMessage(); if (listening) { if (message != null && MessageReceived != null) { MessageReceived(this, new ReceivedMessageEventArgs {Message = message}); } } } } } } private async Task<string> GetMessage() { try { if (dataReader == null) dataReader = new DataReader(socket.InputStream); await dataReader.LoadAsync(4); var messageLen = (uint)dataReader.ReadInt32(); await dataReader.LoadAsync(messageLen); var message = dataReader.ReadString(messageLen); Debug.WriteLine("Message received: " + message); return message; } catch (Exception ex) { Debug.WriteLine("GetMessage: " + ex.Message); } return null; } public event TypedEventHandler<object, ReceivedMessageEventArgs> MessageReceived; private DataReader dataReader; private bool listening;
The StartListeningForMessages basically enters an endless loop – endless that is, until the “listening” is set to “false” - waiting for GetMessage to return something. The GetMessage is almost 100% nicked from the Bluetooth app to app sample at MSDN. the first four bytes are supposed to contain the message length, the rest is payload, hence the two read actions.
Then of course we need a method to actually send messages:
private readonly object lockObject = new object(); public async void SendMessage(string message) { Debug.WriteLine("Send message:" + message); if (socket != null) { try { lock (lockObject) { { if (dataWriter == null) { dataWriter = new DataWriter(socket.OutputStream); } dataWriter.WriteInt32(message.Length); dataWriter.StoreAsync(); dataWriter.WriteString(message); dataWriter.StoreAsync(); } } } catch (Exception ex) { Debug.WriteLine("SendMessage: " + ex.Message); } } } private readonly object lockObject = new object(); private DataWriter dataWriter;
which comes almost directly from my earlier article “Preventing high speed socket communication on Windows Phone 8 going south when using async/await”
And finally we have this brilliant method, which basically resets the TtcSocketHelper class back to its initial status.
public void Reset() { PeerFinder.Stop(); if (dataReader != null) { try { listening = false; if (dataReader != null) { dataReader.Dispose(); dataReader = null; } if (dataWriter != null) { dataWriter.Dispose(); dataWriter = null; } if (socket != null) { socket.Dispose(); socket = null; } } catch (Exception ex) { } } }
To use this class:
- Make sure you app does fire a NavigationMessage as described here
- Make a new TtcSocketHelper.
- Subscribe to its ConnectionStatusChanged event
- Subscribe to its MessageReceived event
- Call Start
- Wait until a TriggeredConnectState.Completed comes by
- Call SendMessage – and see them appear in the method subscribed to MessageReceived on the other phone.
Oh, and don’t forget to set ID_CAP_PROXIMITY in your WMAppManifest.Xaml.right?
The source code – and a working demo of this component – can be found in the demo solution right here. It’s a very simple chat-application built upon TtcSocketHelper. Of course this is all MVVMLight based, and I start off with the basic viewmodel and its properties:
using System.Collections.ObjectModel; using System.Windows; using System.Windows.Input; using GalaSoft.MvvmLight; using GalaSoft.MvvmLight.Command; using GalaSoft.MvvmLight.Messaging; using Windows.Networking.Proximity; using Wp7nl.Devices; namespace TtcDemo.Viewmodels { public class NfcConnectViewModel : ViewModelBase { private TtcSocketHelper ttcSocketHelper; public ObservableCollection<string> ConnectMessages { get; private set; } public ObservableCollection<string> ReceivedMessages { get; private set; } private bool canInitiateConnect; public bool CanInitiateConnect { get { return canInitiateConnect; } set { if (canInitiateConnect != value) { canInitiateConnect = value; RaisePropertyChanged(() => CanInitiateConnect); } } } private bool isConnecting; public bool IsConnecting { get { return isConnecting; } set { if (isConnecting != value) { isConnecting = value; RaisePropertyChanged(() => IsConnecting); } } } private bool canSend; public bool CanSend { get { return canSend; } set { if (canSend != value) { canSend = value; RaisePropertyChanged(() => CanSend); } } } private string message; public string Message { get { return message; } set { if (message != value) { message = value; RaisePropertyChanged(() => Message); } } } } }
We have a TtcSocketHelper itself, and two ObservableCollections of strings. ConnectMessages serves is to report the connection progress, and ReceivedMessages hold the messages received by your ‘opponent’ once the connection is established. Then we have three booleans, that basically turn on or off certain parts of the user interface depending on the state:
- Initially, IsConnecting is true and CanSend is false. That should show a part of the user interface to show ConnectMessages.
- If the app is started by the user, CanInitiateConnect is true as well, so the user can click on some “connect” button as well. If the apps is started by an NFC request, the process of creating a connection is initiated by someone else and the user should not be able to press a “connect” button to prevent the whole process from being south. I my mind I call the instance of the app that has been started by the user “master”, the instance started by the NFC request “slave”.
- If the connection has been successfully established, IsConnecting should become false and CanSend true, disabling the controls handling the connection setup, and enabling the controls doing the actual chatting stuff.
And yeah, I know, usually CanSend != IsConnecting so I could replace this with one boolean. But that makes the designer’s job harder, as I will show further on.
Finally we have the Message property, which is where the message the user wants to send. We’ll come to that later. The viewmodel is initialized via a method “Init”, now called in the constructor:
public NfcConnectViewModel() { Init(); } private void Init() { Messenger.Default.Register<NavigationMessage>(this, ProcessNavigationMessage); if (ConnectMessages == null) { ConnectMessages = new ObservableCollection<string>(); } if (ReceivedMessages == null) { ReceivedMessages = new ObservableCollection<string>(); } if (!IsInDesignMode) { if (ttcSocketHelper == null) { ttcSocketHelper = new TtcSocketHelper(); ttcSocketHelper.ConnectionStatusChanged += ConnectionStatusChanged; ttcSocketHelper.MessageReceived += TtsHelperMessageReceived; } else { CanSend = true; } } IsConnecting = true; }
It doesn’t really do that much – apart from initializing the ObservableCollections, creating an instance of TtcSocketHelper and subscribing to its events, and setting the initial status. There are two things to note here – one, in design mode both CanSend and IsConnecting are true so all the parts of the GUI are enabled. This is to remain friends with the designer, who can now shut on or off both the connection part of the GUI and the messaging part when he/she chooses using Blend, making the design process a lot easier - in stead of having to muck around in your code – or worse, by coming to complain to you.
The second thing to note is that this viewmodel also subscribes to NavigationMessage (just as TtcSocketHelper itself) . This is because the viewmodel likes to know as well if it’s in a master or slave app:
private void ProcessNavigationMessage(NavigationMessage message) { CanInitiateConnect = !message.IsStartedByNfcRequest; }
so it can enable or disable the connect button. The handling of the connection messages is done by ConnectionStatusChanged:
private void ConnectionStatusChanged(object sender, TriggeredConnectionStateChangedEventArgs e) { Deployment.Current.Dispatcher.BeginInvoke(() => ConnectMessages.Add(GetMessageForStatus(e.State))); if (e.State == TriggeredConnectState.Completed) { Deployment.Current.Dispatcher.BeginInvoke(() => { IsConnecting = false; CanSend = true; }); } } private static string GetMessageForStatus(TriggeredConnectState state) { switch (state) { case TriggeredConnectState.Listening: return "Listening...."; case TriggeredConnectState.PeerFound: return "Opponent found"; case TriggeredConnectState.Connecting: return "Opponent found"; case TriggeredConnectState.Completed: return "Connection succesfull!"; case TriggeredConnectState.Canceled: return "Connection canceled"; default: //TriggeredConnectState.Failed: return "Connection failed"; } }
That first adds a message to ConnectMessages using a little helper method GetMessageForStatus (I’d suggest loading this from a resource if you do this in a real app) . After that, if it detects a TriggeredConnectState.Completed message coming by, switches from connect mode to chat mode, so to speak. Since all these events are raised outside of the UI thread, say hello to your old friend Dispatcher to prevent cross-thread access exceptions. Oh, and then of course there’s the little matter of enabling the user actually starting the whole connection process:
public ICommand StartCommmand { get { return new RelayCommand( () => { ConnectMessages.Add("Connect started..."); CanSend = false; CanInitiateConnect = false; ttcSocketHelper.Start(); }); } }
It adds a message to ConnectMessage (to show “see, I am really doing something!”), disables the connect button (as to prevent an annoying Windows Phone certification tester crashing your program) and then it starts the helper. All this is only needed to handle the build-up of the connection. All that actually handles the chatting is merely this:
public ICommand SendComand { get { return new RelayCommand( () => { ttcSocketHelper.SendMessage(Message); Message = string.Empty; } ); } } private void TtsHelperMessageReceived(object sender, ReceivedMessageEventArgs e) { Deployment.Current.Dispatcher.BeginInvoke(() => ReceivedMessages.Add(e.Message)); }
As you can see it’s only a command that relays the message to the TtcSocketHelper and then clears the field, and a simple method listening for messages and adding received message contents to ReceivedMessages, once again with the aid of the Dispatcher.
Since this article is already way longer than I planned, I limit myself to the part of the XAML that is actually interesting:
<!-- Connect panel--> <Grid x:Name="ConnectGrid" Margin="0,0,0,76" Grid.RowSpan="2" Visibility="{Binding IsConnecting, Converter={StaticResource VisibilityConverter}}"> <Grid.RowDefinitions> <RowDefinition Height="425*"/> <RowDefinition Height="106*"/> </Grid.RowDefinitions> <Button Content="Connect" HorizontalAlignment="Center" VerticalAlignment="Top" IsEnabled="{Binding CanInitiateConnect}" Command="{Binding StartCommmand}" Grid.Row="1"/> <ListBox ItemsSource="{Binding ConnectMessages}" Background="#FF091630"/> </Grid> <!-- Message panel--> <Grid x:Name="MessageGrid" Visibility="{Binding CanSend, Converter={StaticResource VisibilityConverter}}" > <Grid.RowDefinitions> <RowDefinition Height="110*"/> <RowDefinition Height="100*"/> <RowDefinition Height="412*"/> </Grid.RowDefinitions> <TextBox Height="72" Margin="0,10,0,0" TextWrapping="Wrap" VerticalAlignment="Center" Text="{Binding Message, Mode=TwoWay}"/> <Button Content="send message" Grid.Row="1" Command="{Binding SendComand, Mode=OneWay}"/> <ListBox Grid.Row="2" ItemsSource="{Binding ReceivedMessages}" Background="#FF091630" Margin="12,0"/> </Grid
You can see ConnectGrid whose visibility is controlled by IsConnecting, and a MessageGrid whose visibility is controlled by CanSend. Then there is the Connect button that is enabled or disabled by CanInitiateConnect. The two faces of the application look like as showed on the right. On the left image you see the app just after connect has been initiated by the ‘master’ the right shows the app after having received a message from the first phone and the user of the second phone responding.
I will add the classes described in this article to the wp8-specific version of my wp7nl CodePlex library soon. In the mean time, you can find them in the Wp7nl.Contrib project of the demo solution.
For the record, there’s also a ResetCommand in the viewmodel that makes it possible to reset the whole connection process, but that’s currently not bound to a button of sorts. I leave that as exercise for the reader ;-)
A final word: I am aware of the fact that I could also have used the Visual State Manager to turn pieces of the GUI on and off (and animate that, too) but I did not want to add even more complexity to this article.
No comments:
Post a Comment