다음을 통해 공유



May 2018

Volume 33 Number 5

[Universal Windows Platform]

Building Connected Apps with UWP and Project Rome

By Tony Champion

In today’s world, building a successful app means moving beyond a single device. Users want apps that span all their devices and even connect with other users. Providing this type of experience can be a daunting challenge, to say the least. To help address this growing need in the ecosystem, Microsoft introduced Project Rome. Project Rome aims to create a more personal OS that spans apps, devices and users. While Project Rome has SDKs available for most major platforms, in this article I’m going to explore using Project Rome to create a team messaging Universal Windows Platform (UWP) app.

Say Hello to Project Rome

Project Rome is an initiative to help you drive user engagement across apps and devices. It’s a collection of APIs that are part of Microsoft Graph and can be categorized into two areas: continue now and continue later.

The Remote System APIs enable an app to break beyond the boundaries of the user’s current device, enabling a continue-now experience. Whether allowing the user to use two devices for a single experience, as with a companion or remote-control app, or allowing multiple users to connect and share a single experience, these APIs provide an expanded view of the user’s current engagement. The team messaging app built in this article will create a shared user experience.

The other half of Project Rome, the Activities APIs, focuses on continuing the user’s experience at a later time. These APIs allow you to record and retrieve specific actions within your app that the user can continue from any device. Though I don’t discuss them in this article, these are definitely worth looking into.

Getting Started

Before diving into building the app, I first need to set up my environment. While the first version of Project Rome has been out for a little while, some of the features used in this article were just released during the recent Fall Creators Update. Therefore, your machine must be running build number 16299 or greater. At this point, this release is available in the slow ring for updates and most machines should be updated correctly.

Running apps with the Remote System APIs with other users requires that shared experiences are enabled on the machine. This can be done in the system settings, in Settings |System | Shared experiences. For the team messaging scenario, you need to enable different users to communicate with your device, which means you need to make sure shared experiences is enabled and that you can share or receive from “Everyone nearby,” as shown in Figure 1.

Enabling Shared Experiences
Figure 1 Enabling Shared Experiences

The final requirement is that your device be discoverable by having some level of connectivity. The Remote System APIs will discover other machines on the same network, as well as those nearby by using Bluetooth. Bluetooth can be enabled in the “Bluetooth and other device settings” page in your system settings.

With the machine set up, let’s begin by creating a new Visual C# app using the Blank App (Universal Windows) template in Visual Studio 2017. Call the app “TeamMessenger.” As mentioned earlier, this project requires the Fall Creators Update, so set the target and minimum versions of the app to “Build 16299” or greater, as shown in Figure 2. This will prevent the app from supporting older versions of Windows 10, but it’s necessary for some of the features touched on in this article.

Setting Target Versions for the App
Figure 2 Setting Target Versions for the App

Note that if you don’t have the Fall Creators update SDKs on your device, the easiest way to obtain them is to update Visual Studio 2017 to the latest release.

The Project Rome APIs are part of the Windows 10 SDK, which means there are no additional SDKs to download or NuGet packages to install in order to build this app. There are, however, a few capabilities that must be added to the app in order for the remote session APIs to work correctly. This can be done by opening the package.appxmanifest file and selecting the Capabilities tab. In the list of available capabilities, make sure the following are checked: Bluetooth, Internet (Client & Server) and Remote System.

Building the Session Connection

This app will consist of two pages, with the first page responsible for creating or joining a remote session using the Remote System APIs. For simplicity, I’ll build this page using the MainPage.xaml that was created with the solution and is already wired into the app to be the first page loaded. The UI has two modes: creating or hosting a session and joining an existing session. Creating a session requires a session name that will be public to users looking to join. Joining an existing session needs to show a list of available nearby sessions. Both modes need a name to be displayed to the user. Figure 3 shows what the resulting UI should look like for MainPage, and the XAML to build this page can be found in Figure 4.

The MainPage UI
Figure 3 The MainPage UI

Figure 4 The MainPage XAML

<Page
  x:Class="TeamMessenger.MainPage"
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:d="https://schemas.microsoft.com/expression/blend/2008"
  xmlns:mc="https://schemas.openxmlformats.org/markup-compatibility/2006"
  xmlns:remotesystems="using:Windows.System.RemoteSystems"
  mc:Ignorable="d">
  <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
    <StackPanel Width="400"
                HorizontalAlignment="Center"
                BorderBrush="Gray"
                BorderThickness="1"
                MaxHeight="600"
                VerticalAlignment="Center"
                Padding="10">
    <RadioButton x:Name="rbCreate"
                GroupName="options"
                IsChecked="True"
                Checked="{x:Bind ViewModel.CreateSession}"
                Content="Create a New Session"/>
    <StackPanel Orientation="Horizontal" Margin="30,10,20,30">
      <TextBlock VerticalAlignment="Center">Session Name :</TextBlock>
      <TextBox Text="{x:Bind ViewModel.SessionName, Mode=TwoWay}"
               Width="200"
               Margin="20,0,0,0"/>
    </StackPanel>
    <RadioButton x:Name="rbJoin"
                GroupName="options"
                Checked="{x:Bind ViewModel.JoinSession}"
                Content="Join Session"/>
    <ListView ItemsSource="{x:Bind ViewModel.Sessions}"
              SelectedItem="{x:Bind ViewModel.SelectedSession, Mode=TwoWay}"
              IsItemClickEnabled="True"
              Height="200"
              BorderBrush="LightGray"
              BorderThickness="1"
              Margin="30,10,20,30">
      <ListView.ItemTemplate>
        <DataTemplate x:DataType="remotesystems:RemoteSystemSessionInfo">
          <TextBlock Text="{x:Bind DisplayName}"/>
        </DataTemplate>
      </ListView.ItemTemplate>
    </ListView>
    <StackPanel Orientation="Horizontal">
      <TextBlock VerticalAlignment="Center">Name : </TextBlock>
      <TextBox Text="{x:Bind ViewModel.JoinName, Mode=TwoWay}"
               Width="200"
               Margin="20,0,0,0"/>
    </StackPanel>
    <Button Content="Start"
            Margin="0,30,0,0"
            Click="{x:Bind ViewModel.Start}"/>
    </StackPanel>
  </Grid>
</Page>

After creating the XAML for the page, create a new ViewModels folder in the app, then add a new public class in that folder called MainViewModel.cs. This view model will be wired into the view to handle the functionality.

The first portion of the view model will handle managing state for the radio buttons that determine if the user is creating a new session or joining an existing one. The state is held in a bool called IsNewSession. Two methods are used to switch the status of this bool, CreateSession and JoinSession:

public bool IsNewSession { get; set; } = true;
public void CreateSession()
{
  IsNewSession = true;
}
public void JoinSession()
{
  IsNewSession = false;
}

The Checked event for each radio button is bound to one of these methods.

The remaining UI elements are tracked with simple properties. The session name and user name are bound to the SessionName and JoinName properties. The SelectedSession property binds to the SelectedItem property in the ListView and its ItemsSource is bound to the Sessions property:

public string JoinName { get; set; }
public string SessionName { get; set; }
public object SelectedSession { get; set; }
public ObservableCollection<
  RemoteSystemSessionInfo> Sessions { get; } =
  new —ObservableCollection<
  RemoteSystemSessionInfo>();

The view model has two events that will be used to let the view know if a connection to a session was successful or not:

public event EventHandler SessionConnected =
  delegate { };
public event EventHandler<SessionCreationResult> ErrorConnecting = delegate { };

Finally, the Start button is bound to a Start method. This method can be left empty for the moment.

Once the view model has been completed, the codebehind for MainPage needs to create a public property that’s an instance of the MainViewModel. This is what allows x:Bind to build the compile-time bindings. In addition, it needs to subscribe to the two events created in the view model. If a connection is successfully made, I’ll navigate to a new page, MessagePage. If the connection fails, a MessageDialog will be displayed, informing the user of the failed connection. Figure 5 contains the code for the MainPage.xaml.cs.

Figure 5 MainPage.xaml Codebehind

public sealed partial class MainPage : Page
{
  public MainPage()
  {
    this.InitializeComponent();
    ViewModel.SessionConnected += OnSessionConnected;
    ViewModel.ErrorConnecting += OnErrorConnecting;
  }
  private async void OnErrorConnecting(object sender, SessionCreationResult e)
  {
    var dialog = new MessageDialog("Error connecting to a session");
    await dialog.ShowAsync();
  }
  private void OnSessionConnected(object sender, EventArgs e)
  {
    Frame.Navigate(typeof(MessagePage));
  }
  public MainViewModel ViewModel { get; } = new MainViewModel();
}

Defining the Data Models

Before digging in the heart of the app, you need to define a couple of data models that will be used in the app. Create a Models folder in the app and then create two classes in it: User and UserMessage. As the name suggests, the User model will track information about the users connected to the app:

public class User
{
  public string Id { get; set; }
  public string DisplayName { get; set; }
}

The UserMessage class will contain the message content, the user creating the content and when the message was created:

public class UserMessage
{
  public User User { get; set; }
  public string Message { get; set; }
  public DateTime DateTimeStamp { get; set; }
}

Creating a Session

With the code for the main page fairly complete, I can start building out RemoteSessionManager, which will be used to wrap the Remote Systems API. Add a new public class called RemoteSessionManager to the root directory of the app. The app will use a single shared instance of the RemoteSessionManager, so you can add a static property to the App class in App.xaml.cs:

public static RemoteSessionManager SessionManager { get; } = new RemoteSessionManager();

Before the app can access any of the Remote Systems APIs, it must first obtain permission from the user. This permission is obtained by calling the static method RemoteSystem.RequestAccessAsync:

RemoteSystemAccessStatus accessStatus = 
  await RemoteSystem.RequestAccessAsync();
if (accessStatus != RemoteSystemAccessStatus.Allowed)
{
  // Access is denied, shortcut workflow
}

The method will return a RemoteSystemAccessStatus enum that can be used to determine if access was granted. This method must be called from the UI thread so it can successfully prompt the user. Once the user has granted or denied permission to the app, any subsequent calls will automatically return the user’s preference. For this app, this permission will be added to the session discovery because it’s called first in the workflow.

Note that all of the Remote System APIs can be found in the Windows.System.RemoteSystem namespace.

The first method to add to the RemoteSessionManager class is the CreateSession method. Because there are several results that can be returned from this method, I’ll wrap those up in a new enum—SessionCreationResult. SessionCreationResult has four possible values: success and three different failures. A session can fail to create because the user didn’t grant access to the app; the app currently has too many sessions running; or a system error failed to create the session:

public enum SessionCreationResult
{
  Success,
  PermissionError,
  TooManySessions,
  Failure
}

Remote sessions are managed by a RemoteSystemSession­Controller. When creating a new RemoteSystemSessionController instance, you must pass in a name that will be displayed to devices attempting to join the session.

Once the controller is requested, a session can be started by calling the CreateSession method. This method returns a RemoteSystemSessionCreationResult containing a status and the new instance of the session if it was successful. The RemoteSessionManager will store the new controller and session in private variables.

A new public property, IsHost, should be added to the manager, as well as to determine workflow. During the CreateSession method, this value is set to true, identifying this app as the host. Another public property, CurrentUser, provides an instance of the user on the machine and will be used for messaging. The session manager also maintains an ObservableCollection of users in the current session. This collection is initialized with the newly created user. For the host of the session, this instance gets created in the CreateSession method. The resulting additions to the RemoteSessionManager are shown in Figure 6.

Figure 6 The CreateSession Method

private RemoteSystemSessionController _controller;
private RemoteSystemSession _currentSession;
public bool IsHost { get; private set; }
public User CurrentUser { get; private set; }
public ObservableCollection<User> Users { get; } =
  new ObservableCollection<User>();
public async Task<SessionCreationResult> CreateSession(
  string sessionName, string displayName)
{
  SessionCreationResult status = SessionCreationResult.Success;
  RemoteSystemAccessStatus accessStatus = await RemoteSystem.RequestAccessAsync();
  if (accessStatus != RemoteSystemAccessStatus.Allowed)
  {
    return SessionCreationResult.PermissionError;
  }
  if (_controller == null)
  {
    _controller = new RemoteSystemSessionController(sessionName);
    _controller.JoinRequested += OnJoinRequested;
  }
  RemoteSystemSessionCreationResult createResult =
    await _controller.CreateSessionAsync();
  if (createResult.Status == RemoteSystemSessionCreationStatus.Success)
  {
    _currentSession = createResult.Session;
    InitParticipantWatcher();
    CurrentUser = new User() { Id = _currentSession.ControllerDisplayName,
      DisplayName = displayName };
    Users.Add(CurrentUser);
    IsHost = true;
  }
  else if(createResult.Status ==
    RemoteSystemSessionCreationStatus.SessionLimitsExceeded)
  {
    status = SessionCreationResult.TooManySessions;
  } else
  {
    status = SessionCreationResult.Failure;
  }
  return status;
}

There are three more items that need to be added to RemoteSessionManager to complete the CreateSession method. The first is an event handler for when a user attempts to join a session and the JoinRequested event is raised on the session. The OnJoinRequested method will automatically accept any user trying to join. This could be extended to prompt the host for approval before the user is joined to the session. The request information is provided as a RemoteSystemSessionJoinRequest included in the RemoteSystem­SessionJoinRequestedEventArgs parameter of the event handler. Invoking the Accept method will add the user to the session. The following code includes the new event to add to RemoteSessionManager, as well as the completed OnJoinRequested method:

private void OnJoinRequested(RemoteSystemSessionController sender,
  RemoteSystemSessionJoinRequestedEventArgs args)
{
  var deferral = args.GetDeferral();
  args.JoinRequest.Accept();
  deferral.Complete();
}

The session manager can monitor when participants are added or removed from the current session through the RemoteSystemSessionParticipantWatcher. This class monitors the participants and raises either an Added or Removed event when needed. When a user joins a session already in progress, each participant already in the current session will receive an Added event. The app will take this series of events and determine which participant is the host by matching the DisplayName against the session’s Controller­DisplayName. This will allow participants to communicate directly with the host. The session manager maintains a participant watcher as a private variable that’s initialized in the InitParticipantWatcher. This method is called whether creating a session or joining an existing session. Figure 7 contains the new additions. You’ll notice that for this workflow you need to know when a participant is removed only if you’re the host, and if a participant is added if you’re joining a session. As a host, the RemoteSessionManager is concerned only when a participant leaves the session. The host will be notified directly by the participant when they join, as you’ll see later in the article. Participants only need to determine the current host account.

Figure 7 InitParticipantWatcher

private RemoteSystemSessionParticipantWatcher _participantWatcher;
private void InitParticipantWatcher()
{
  _participantWatcher = _currentSession.CreateParticipantWatcher();
  if (IsHost)
  {
    _participantWatcher.Removed += OnParticipantRemoved;
  }
  else
  {
    _participantWatcher.Added += OnParticipantAdded;
  }
  _participantWatcher.Start();
}
private void OnParticipantAdded(RemoteSystemSessionParticipantWatcher watcher,
  RemoteSystemSessionParticipantAddedEventArgs args)
{
  if(args.Participant.RemoteSystem.DisplayName ==
    _currentSession.ControllerDisplayName)
  {
    Host = args.Participant;
  }
}
private async void OnParticipantRemoved(RemoteSystemSessionParticipantWatcher watcher,
  RemoteSystemSessionParticipantRemovedEventArgs args)
{
  var qry = Users.Where(u => u.Id == args.Participant.RemoteSystem.DisplayName);
  if (qry.Count() > 0)
  {
    var dispatcher = CoreApplication.MainView.CoreWindow.Dispatcher;
    await dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.High,
      () => { Users.Remove(qry.First()); });
    await BroadCastMessage("users", Users);
  }
}

The last thing the CreateSession method needs added to the class is an event to let consumers know if and when the session is disconnected. The new SessionDisconnected event can be defined as follows:

public event EventHandler<RemoteSystemSessionDisconnectedEventArgs> SessionDisconnected =
  delegate { };

Joining a Session

Now that the app is able to broadcast a new remote session, the next thing to implement is the ability to join that session from another machine. There are two steps to joining a remote session: discovery and then connecting to the session.

Discovering a Session An app is able to discover nearby remote sessions through the RemoteSystemSessionWatcher, which is created from the static CreateWatcher method. This class raises events every time a session is added or removed. Add a new method—DiscoverSessions—to the RemoteSessionManager. This method will create a RemoteSystemSessionWatcher as a private variable to the class and handle the Added and Removed events. These events will be wrapped by two new events added to RemoteSessionManager: SessionAdded and SessionRemoved. Because this will be another entry point for users initializing remote sessions, you need to make sure to add a call to RemoteSystem.RequestAccessAsync. Figure 8 contains the private variable, the two events and the complete DiscoverSessions method.

Figure 8 Discovering Sessions

private RemoteSystemSessionWatcher _watcher;
public event EventHandler<RemoteSystemSessionInfo> SessionAdded = delegate { };
public event EventHandler<RemoteSystemSessionInfo> SessionRemoved = delegate { };
public async Task<bool> DiscoverSessions()
{
  RemoteSystemAccessStatus status = await RemoteSystem.RequestAccessAsync();
  if (status != RemoteSystemAccessStatus.Allowed)
  {
    return false;
  }
  _watcher = RemoteSystemSession.CreateWatcher();
  _watcher.Added += (sender, args) =>
  {
    SessionAdded(sender, args.SessionInfo);
  };
  _watcher.Removed += (sender, args) =>
  {
    SessionRemoved(sender, args.SessionInfo);
  };
  _watcher.Start();
  return true;
}

It’s now possible to wire in the MainViewModel to update the Sessions property with locally available sessions. Because the DiscoverSessions method is asynchronous, the constructor of the MainViewModel needs to initialize a Task to invoke it. The initializing method should also register and handle the SessionAdded and SessionRemoved events. Because these events won’t be firing on the UI thread when updating the Sessions property, it’s important to use a CoreDispatcher. The updates to MainViewModel are in Figure 9.

Figure 9 Adding Session Discovery

public MainViewModel()
{
  _initSessionManager = InitSessionManager();
}
private Task _initSessionManager;
private async Task InitSessionManager()
{
  App.SessionManager.SessionAdded += OnSessionAdded;
  App.SessionManager.SessionRemoved += OnSessionRemoved;
  await App.SessionManager.DiscoverSessions();
}
private async void OnSessionAdded(object sender, RemoteSystemSessionInfo e)
{
  var dispatcher = CoreApplication.MainView.CoreWindow.Dispatcher;
  await dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.High,
    () => { Sessions.Add(e); });
}
private async void OnSessionRemoved(object sender, RemoteSystemSessionInfo e)
{
  if (Sessions.Contains(e))
  {
    var dispatcher = CoreApplication.MainView.CoreWindow.Dispatcher;
    await dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.High,
      () => { Sessions.Remove(e); });
  }
}

Connecting to the Session Once a user has identified the session they want to join and supplies a name, the RemoteSession­Manager must be able to connect them to the selected session. This will be handled by a new JoinSession method on the RemoteSessionManager, which takes the selected session and the entered display name as parameters.

The JoinSession method begins by calling the JoinAsync method on the supplied session. In return, this will fire the JoinRequested event on the host session. If the host approves the request, a success status is returned and the CurrentUser is set using the display name. As with the CreateSession method, the InitParticipantWatcher method is invoked to register an event handler for when participants are added to the session. The JoinSession method is shown in Figure 10.

Figure 10 The JoinSession Method

public async Task<bool> JoinSession(RemoteSystemSessionInfo session, string name)
{
  bool status = true;
  RemoteSystemSessionJoinResult joinResult = await session.JoinAsync();
  if (joinResult.Status == RemoteSystemSessionJoinStatus.Success)
  {
    _currentSession = joinResult.Session;
    CurrentUser = new User() { DisplayName = name };
  }
  else
  {
    status = false;
  }
  InitParticipantWatcher();
  return status;
}

The final step involved in joining a session is to use the functionality created in the RemoteSessionManager to either create or join a session. Figure 11 shows the Start method in the MainViewModel that’s bound to the Start button in MainPage. The workflow of the method is straightforward. Depending on IsNewSession, it calls either the CreateSession method or the JoinSession method. The results are returned by raising either the SessionConnected or ErrorConnecting events. If the session is successful, the app navigates to the MessagePage, which I’ll build in the next section.

Figure 11 Starting a Session

public async void Start()
{
  if(IsNewSession)
  {
    var result = await App.SessionManager.CreateSession(SessionName, JoinName);
    if(result == SessionCreationResult.Success)
    {
      SessionConnected(this, null);
    } else
    {
      ErrorConnecting(this, result);
    }
  } else
  {
    if(SelectedSession != null)
    {
      var result = await App.SessionManager.JoinSession(
        SelectedSession as RemoteSystemSessionInfo, JoinName);
      if(result)
      {
        SessionConnected(this, null);
      } else
      {
        ErrorConnecting(this, SessionCreationResult.Failure);
      }
    }
  }
}

Keep the Apps Talking

At this point, the app can successfully create or join a session and has a messaging UI that’s ready to be used. The only remaining step is enabling the devices to communicate with each other. This is accomplished by using the Remote System API to send ValueSet instances between the machines. Each ValueSet is a set of key/value pairs of serialized payloads.

Receiving Messages Messages are transmitted within a session through a RemoteSystemSessionMessageChannel. A session can have multiple channels; however, this app will need only a single channel. In the RemoteSessionManager, I’m going to add a Start­ReceivingMessages method. This method will create a new message channel that’s stored in a private variable and then add a handler to the ValueSetReceived event.

Messages are sent as text and because the app is using classes as messages, I need to serialize the data. When the ValueSet is received from the channel, a DataContractJsonSerializer is used to rehydrate the message classes in the DeserializeMessage class. Because I’m unable to tell what type of message is serialized, the app will send each type of message as a different value in the ValueSet. The DeserializeMessage class will determine which key is used and return the correct class.

Once the message class is ready, the manager class will act on the message depending on its type. As you’ll see, participants will announce themselves to the host by sending their CurrentUser instance. In response, the host will broadcast the updated user list to all participants. If the session manager receives the list of participants, it will update the Users collections with the updated data. The final option, a UserMessage, will raise a new Message­Received event that passes the message and the participant who sent the message. These additions to the RemoteSessionManager can be found in Figure 12.

Figure 12 Receiving Messages

private RemoteSystemSessionMessageChannel _messageChannel;
public event EventHandler<MessageReceivedEventArgs> MessageReceived = delegate { };
public void StartReceivingMessages()
{
  _messageChannel = new RemoteSystemSessionMessageChannel(_currentSession, "OpenChannel");
  _messageChannel.ValueSetReceived += OnValueSetReceived;
}
private object DeserializeMessage(ValueSet valueSet)
{
  Type serialType;
  object data;
   if(valueSet.ContainsKey("user"))
   {
    serialType = typeof(User);
    data = valueSet["user"];
  } else if (valueSet.ContainsKey("users"))
  {
    serialType = typeof(List<User>);
    data = valueSet["users"];
  } else
  {
    serialType = typeof(UserMessage);
    data = valueSet["message"];
  }
  object value;
  using (var stream = new MemoryStream((byte[])data))
  {
    value = new DataContractJsonSerializer(serialType).ReadObject(stream);
  }
  return value;
}
private async void OnValueSetReceived(RemoteSystemSessionMessageChannel sender,
  RemoteSystemSessionValueSetReceivedEventArgs args)
{
  var data = DeserializeMessage(args.Message);
  if (data is User)
  {
    var user = data as User;
    user.Id = args.Sender.RemoteSystem.DisplayName;
    if (!Users.Contains(user))
    {
      var dispatcher = CoreApplication.MainView.CoreWindow.Dispatcher;
      await dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.High,
        () => { Users.Add(user); });
    }
    await BroadcastMessage("users", Users.ToList());
  }
  else if (data is List<User>)
  {
    var users = data as List<User>;
    Users.Clear();
    foreach(var user in users)
    {
      Users.Add(user);
    }
  }
  else
  {
    MessageReceived(this, new MessageReceivedEventArgs()
    {
      Participant = args.Sender,
      Message = data
    });
  }
}

Figure 12 includes a new event handler class, MessageReceived­EventArgs, that must also be created. This class contains two properties: the sender and the message:

public class MessageReceivedEventArgs
{
  public RemoteSystemSessionParticipant Participant { get; set; }
  public object Message { get; set; }
}

Sending Messages The Remote Systems API provides two methods for delivering messages to other users. The first is to broadcast a message to all of the users in the session. This approach will be used for two of our message types, the UserMessage and the list of Users. Let’s create a new method, BroadcastMessage, in the RemoteSystemManager. This method takes a key and the message as parameters. Using a DataContractJsonSerializer, I serialize the data and use the BroadcastValueSetAsync method to send the message to all of the users, as shown in Figure 13.

Figure 13 Broadcasting a Message

public async Task<bool> BroadcastMessage(string key, object message)
{
  using (var stream = new MemoryStream())
  {
    new DataContractJsonSerializer(message.GetType()).WriteObject(stream, message);
    byte[] data = stream.ToArray();
    ValueSet msg = new ValueSet();
    msg.Add(key, data);
    await _messageChannel.BroadcastValueSetAsync(msg);
  }
  return true;
}

The second approach is to send a message to a single participant. This approach is similar to broadcasting a message, except it uses the SendValueSetAsync method to message a participant directly. This final method to the RemoteSystemManager, SendMessage, can be found in Figure 14.

Figure 14 Sending a Direct Message

public async Task<bool> SendMessage(string key, 
  object message, 
  RemoteSystemSessionParticipant participant)
{
  using (var stream = new MemoryStream())
  {
    new DataContractJsonSerializer(message.GetType()).WriteObject(stream, message);
    byte[] data = stream.ToArray();
    ValueSet msg = new ValueSet();
    msg.Add(key, data);
    await _messageChannel.SendValueSetAsync(msg, participant);
  }
  return true;
}

Building the Messaging Page

With the messaging now in place, it’s time to put it to use and finish the app. Add a new Blank Page to the app, MessagePage.xaml. This page will consist of a list of users, a message window and input fields for adding a message. The full XAML can be found in Figure 15.

Figure 15 The MessagePage XAML

<Page
  x:Class="TeamMessenger.MessagePage"
  xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="using:TeamMessenger"
  xmlns:models="using:TeamMessenger.Models"
  xmlns:d="https://schemas.microsoft.com/expression/blend/2008"
  xmlns:mc="https://schemas.openxmlformats.org/markup-compatibility/2006"
  xmlns:remotesystems="using:Windows.System.RemoteSystems"
  mc:Ignorable="d">
  <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
    <Grid.ColumnDefinitions>
      <ColumnDefinition MinWidth="200" Width="Auto"/>
      <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Grid VerticalAlignment="Stretch"
          BorderBrush="Gray" BorderThickness="0,0,1,0">
      <ListView ItemsSource="{x:Bind ViewModel.Users}">
        <ListView.ItemTemplate>
          <DataTemplate x:DataType="models:User">
            <TextBlock Height="25"
                       FontSize="16"
                       Text="{x:Bind DisplayName}"/>
          </DataTemplate>
        </ListView.ItemTemplate>
      </ListView>
    </Grid>
    <Grid Grid.Column="1" Margin="10,0,10,0">
      <Grid.RowDefinitions>
        <RowDefinition Height="*"/>
        <RowDefinition Height="Auto"/>
      </Grid.RowDefinitions>
      <ListView x:Name="lvMessages" ItemsSource="{x:Bind ViewModel.Messages}">
        <ListView.ItemTemplate>
          <DataTemplate x:DataType="models:UserMessage">
            <StackPanel Orientation="Vertical"
                        Margin="10,20,10,5">
              <TextBlock TextWrapping="WrapWholeWords"
                         Height="Auto"
                         Text="{x:Bind Message}"/>
              <StackPanel Orientation="Horizontal"
                          Margin="20,5,0,0">
                <TextBlock Text="{x:Bind User.DisplayName}"
                           FontSize="12"
                           Foreground="Gray"/>
                <TextBlock Text="{x:Bind DateTimeStamp}"
                           Margin="20,0,0,0"
                           FontSize="12"
                           Foreground="Gray"/>
              </StackPanel>
            </StackPanel>
          </DataTemplate>
        </ListView.ItemTemplate>
      </ListView>
      <Grid Grid.Row="1" Height="60"
            Background="LightGray">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="*"/>
          <ColumnDefinition Width="Auto"/>
        </Grid.ColumnDefinitions>
        <TextBox Text="{x:Bind ViewModel.NewMessage, Mode=TwoWay}"
                 Margin="10"/>
        <Button Grid.Column="1" Content="Send"
                Click="{x:Bind ViewModel.SubmitMessage}"
                Margin="10"/>
      </Grid>
    </Grid>
  </Grid>
</Page>

Like MainPage, MessagePage will need a view model. Add a new class, MessageViewModel, to the ViewModels folder. This view model will need to support INotifyPropertyChanged to allow the two-way binding to function properly. This view model will contain three properties: Users, Messages and NewMessage. The Users will simply expose the RemoteSessionManager’s User collection to the view. Messages will be an ObservableCollection of UserMessage objects received and a NewMessage string containing the text to send as a new message. There is also a single event, MessageAdded, that will be used by the codebehind in MessagePage. In the constructor of the view model, I need to map the Users property, invoke the StartReceivingMessages method in RemoteSessionManager, and register for the MessageReceived event, as shown in Figure 16. The constructor also includes the implementation of INotifiyPropertyChanged.

Figure 16 MessageViewModel Constructor

public event PropertyChangedEventHandler PropertyChanged = delegate { };
public event EventHandler MessageAdded = delegate { };
public ObservableCollection<UserMessage> Messages { get; private set; }
public ObservableCollection<User> Users { get; private set; }
private string _newMessage;
public string NewMessage {
  get { return _newMessage; }
  set
  {
    _newMessage = value;
    PropertyChanged(this, new
    PropertyChangedEventArgs(nameof(NewMessage)));
  }
}
public MessageViewModel()
{
  Users = App.SessionManager.Users;
  Messages = new ObservableCollection<UserMessage>();
  App.SessionManager.StartReceivingMessages();
  App.SessionManager.MessageReceived += OnMessageRecieved;
  RegisterUser();
}

In the constructor there’s a call to RegisterUser. This method will send the CurrentUser that was created when joining a session to the host. This announces to the host that a new user has joined and what the display name is. In response, the host will send out the current list of users to be displayed in the app:

private async void RegisterUser()
{
  if(!App.SessionManager.IsHost)
    await App.SessionManager.SendMessage("user", App.SessionManager.CurrentUser,
                                                 App.SessionManager.Host);
}

The final piece of the view model is to broadcast a new message from the user. The SubmitMessage method constructs a new UserMessage and calls the BroadcastMessage method on the RemoteSessionManager. It then clears out the NewMessage value and raises the MessageAdded event, as shown in Figure 17.

Figure 17 Submitting a Message

public async void SubmitMessage()
{
  var msg = new UserMessage()
  {
    User = App.SessionManager.CurrentUser,
    DateTimeStamp = DateTime.Now,
    Message = NewMessage
  };
  await App.SessionManager.BroadcastMessage("message", msg);
  Messages.Add(msg);
  NewMessage = "";
  MessageAdded(this, null);
}

In the codebehind for MessagePage, shown in Figure 18, I need to do two things: create an instance of the MessageViewModel for the XAML to reference and handle the MessageAdded event. In the event handler I instruct the ListView to scroll to the bottom of the list where the latest message is visible.

Figure 18 MessagePage Codebehind

public sealed partial class MessagePage : Page
{
  public MessagePage()
  {
    this.InitializeComponent();
    ViewModel.MessageAdded += OnMessageAdded;
  }
  private void OnMessageAdded(object sender, EventArgs e)
  {
    lvMessages.ScrollIntoView(ViewModel.Messages.Last());
  }
  public MessageViewModel ViewModel { get; } = new MessageViewModel();
}

The Team Messaging app should now be ready to run. On one machine run the app and create a new session. Then launch the app on a second machine, which should show the newly created message. Once you join the session you’ll be brought to the new message page where you can begin chatting with others in the session, as shown in Figure 19. You have now created a multiuser app using the Remote System API.

Multiuser Messaging
Figure 19 Multiuser Messaging

Wrapping Up

Creating successful user experiences within apps often requires looking beyond a single device or platform or even user. Microsoft developed Project Rome to enable developers to provide this level of experience within their apps. In this article I built a UWP app using the Remote Systems API; however, by using the Project Rome SDKs available for other platforms, you could extend this app to work on multiple platforms. When building the next great experience for your users, remember to consider how Project Rome can help you make your app more personal. The source code for this article can be found at bit.ly/2FWtCc5.


Tony Champion is a software architect with more than 20 years of experience developing with Microsoft technologies. As the president of Champion DS and its lead software architect, he remains active in the latest trends and technologies, creating custom solutions on Microsoft platforms. His list of clients span multiple industries and includes companies such as: Schlumberger, Microsoft, Boeing, MLB and Chevron/Philips. Champion is an active participant in the community as a six-year Microsoft MVP, international speaker, published author and blogger.

Thanks to the following Microsoft technical expert who reviewed this article: Shawn Henry