diff --git a/samples/ReliableChatRoom/.gitignore b/samples/ReliableChatRoom/.gitignore
index 064cf644..e78f5313 100644
--- a/samples/ReliableChatRoom/.gitignore
+++ b/samples/ReliableChatRoom/.gitignore
@@ -1,5 +1,2 @@
-bin/
-obj/
+TestResults/
.vs/
-**.csproj.user
-*.sln
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/README.md b/samples/ReliableChatRoom/README.md
new file mode 100644
index 00000000..d4500157
--- /dev/null
+++ b/samples/ReliableChatRoom/README.md
@@ -0,0 +1,312 @@
+# Build A SignalR-based Reliable Mobile Chat Room Server
+
+This tutorial shows you how to build a reliable mobile chat room server with SignalR. You'll learn how to:
+
+> **✓** Build a simple reliable chat room with Azure SignalR Service.
+>
+> **✓** Integrate chat room server with Firebase Notification.
+>
+> **✓** Use Azure Storage table and blob services.
+>
+> **✓** Deploy it to Azure App Service.
+
+## Prerequisites
+* Install [.NET Core 3.1 SDK](https://dotnet.microsoft.com/download/dotnet-core/3.1)
+* Install [Visual Studio 2019](https://visualstudio.microsoft.com/vs/) (Version >= 16.3)
+* Create an [Azure SignalR Service](https://azure.microsoft.com/en-us/services/signalr-service/)
+* Create an [Azure Storage Account](https://docs.microsoft.com/en-us/azure/storage/common/storage-account-overview)
+* Create an [Azure Notification Hub](https://azure.microsoft.com/en-us/services/notification-hubs/)
+* Create a [Google Firebase Service](https://firebase.google.com/)
+
+## Abstract
+
+There are four main conponents in a reliable chat room server-side system:
+1. [**Google Firebase**](#create-and-setup-your-google-firebase-service-for-notificaiton) and [**Azure Notification Hub**](#create-and-setup-your-azure-notification-hub-service) that wraps it as notification service
+2. [**Azure Storage**](#create-your-azure-storage-account) as message storage service
+3. [**Azure SignalR Service**](#create-your-azure-signalr-service) as message delievery service
+4. Local [**.NET chat room server**](#configure-your-reliable-chat-room-server)
+
+
+
+This repo will focus on **.NET Chat Room Server** and its interaction with those components mentioned above.
+
+## Create and Setup Your Google Firebase Service For Notificaiton
+
+See Google [reference](https://firebase.google.com/docs/cloud-messaging/android/client) of *Set up a Firebase Cloud Messaging client app on Android*.
+
+Get the server key we need to build the chat room server:
+
+1. Goto [Firebase Console](https://console.firebase.google.com/) and select your client app
+
+ 
+
+2. Goto `Settings` -> `Project Settings` -> `Cloud Messaging Tab` and then copy your server key
+
+ If there is no server key here, add one.
+ 
+ You will need to use the server key in Azure Notification Hub Service.
+
+## Create and Setup Your Azure Notification Hub Service
+
+See [reference](https://docs.microsoft.com/en-us/azure/notification-hubs/notification-hubs-android-push-notification-google-fcm-get-started) of *Tutorial: Send push notifications to Android devices using Firebase SDK version 0.6*
+
+One core thing to do is adding your Firebase Server Key into your Notification Hub:
+
+1. Enter your Notification Hub in [Azure Portal](https://ms.portal.azure.com/) and click `Google (GCM/FCM)`
+
+ 
+
+2. Paste your server key in the server input
+
+ 
+
+
+## Create Your Azure Storage Account
+
+See [reference](https://docs.microsoft.com/en-us/azure/storage/common/storage-account-create?tabs=azure-portal) of *Create a storage account*.
+
+We will need connection string for chat room server:
+
+1. Enter your Storage Account in [Azure Portal](https://ms.portal.azure.com/) and click `Access Keys`
+
+ 
+
+2. Copy your Storage Account connection string
+
+ 
+
+## Create Your Azure SignalR service
+
+See [reference](https://docs.microsoft.com/en-us/azure/azure-signalr/signalr-quickstart-dotnet-core#:~:text=To%20create%20an%20Azure%20SignalR,the%20results%2C%20and%20select%20Create.) about *Quickstart: Create a chat room by using SignalR Service*.
+
+We will need connection string for chat room server:
+
+1. Enter your SignalR Service in [Azure Portal](https://ms.portal.azure.com/) and click `Keys`
+
+ 
+
+2. Copy your SignalR Service connection string
+
+ 
+
+
+## Configure Your Reliable Chat Room Server (Locally)
+
+See [reference](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-3.1&tabs=windows) about *Safe storage of app secrets in development in .NET Core*.
+
+0. Clone/download the source code from repo.
+
+ ```dotnet cli
+ git clone https://github.com/$USERNAME/AzureSignalR-samples.git
+ ```
+
+1. Change your directory to the project directory
+
+ ```dotnet cli
+ cd ./samples/ReliableChatRoom/ReliableChatRoom/
+ ```
+
+2. Initialize user-secrets
+
+ ```dotnetcli
+ dotnet user-secrets init
+ ```
+
+3. Add user secrets
+
+ ```dotnetcli
+ dotnet user-secrets set "Azure:SignalR:ConnectionString" $YOUR_SIGNALR_CONNECTION_STRING
+ dotnet user-secrets set "ConnectionStrings:AzureStorageAccountConnectionString" $YOUR_STORAGE_ACCOUNT_CONNECTION_STRING
+ dotnet user-secrets set "ConnectionStrings:AzureNotificationHub:HubName" $YOUR_HUB_NAME
+ dotnet user-secrets set "ConnectionStrings:AzureNotificationHub:ConnectionString" $YOUR_NOTIFICATION_HUB_CONNECTION_STRING
+ ```
+
+## Configure Your Reliable Chat Room Server (Remotely on `Azure App Service`)
+
+1. In [Azure Portal](https://portal.azure.com/) create a `Web App`.
+
+ 
+
+ Enter the required information in the next page.
+
+ **NOTICE:** `Azure Web App` is now moved to `Azure App Service`, we will refer to it as `Azure App Service` in the README context.
+
+2. In [Azure Portal](https://portal.azure.com/) -> `Home` -> `YOUR_WEB_APP(APP_SERVICE)`
+
+ 
+
+ Copy the app service URL to your text editor. You will need it when integrating `Reliable ChatRoom Server` with `Android Mobile ChatRoom`:
+
+ 
+
+3. In `Configuration` tab (under `Settings` section), add your `Azure Notification Hub`, `Azure SignalR Service`, and `Azure Storage Account` connection strings
+
+ |Name|Value|
+ |----|-----|
+ |ConnectionStrings__AzureNotificationHub__ConnectionString|Your `Azure Notification Hub` Connection String|
+ |ConnectionStrings__AzureNotificationHub__HubName| Your `Azure Notification Hub` Hub Name|
+ |ConnectionStrings__AzureStorageAccountConnectionString| Your `Azure Storage Account` Connection String|
+ |Azure__SignalR__ConnectionString| Your `Azure SignalR Service` Connection String|
+
+4. Publish the `ReliableChatRoom` to `Azure App Service`
+
+ 1. Open the `ReliableChatRoom` with Visual Studio 2019
+
+ 2. Right-click on project -> Choose `Publish...`
+
+ 
+
+ 3. Follow the instruction to publish the server-side code to your newly-created `Azure App Service`
+
+4. In `App Service logs` tab (under `Monitoring` section), turn on `Applicatin Logging (Filesystem)` and then set `Level` to `Information`.
+
+ 
+
+ Don't forget to hit `Save` button.
+
+5. To check realtime logging information, enter `Log stream` tag.
+
+ 
+
+
+
+## Run Your Reliable Chat Room Server
+
+1. To run the server locally, use the following command:
+
+ ```dotnet cli
+ dotnet run
+ ```
+
+ If succeed, the output will be like:
+ ```dotnet cli
+ Hosting environment: Development
+ Content root path: *\source\repos\AzureSignalR-samples\samples\ReliableChatRoom\ReliableChatRoom
+ Now listening on: http://localhost:5000
+ Now listening on: https://localhost:5001
+ Application started. Press Ctrl+C to shut down.
+ ```
+
+2. To run the server remotely, please follow these steps:
+
+ 1. In `YOUR_AZURE_APP_SERVICE` -> `Overview` -> Click `Start`
+
+ 
+
+## How Does Reliable Chat Protocol Work?
+
+1. Client enters the chat room
+
+ 
+
+2. Client broadcasts a message to all other clients
+
+ 
+
+3. Client sends a private message to another client
+
+ 
+
+4. Client pulls history messages from server
+
+ 
+
+5. Client pull image content from server
+
+ 
+
+6. Client leaves the chat room
+
+ 
+
+## How Can I Integrate the Reliable Chat Room Server with Clients
+
+We provide a sample Android mobile chat room app which works with the `Reliable Chat Room Server`. Please see [reference](../MobileChatRoom/.)
+
+## Server-side Interface Specification
+
+Overview:
+
+
+
+C Sharp view, see [source code](./ReliableChatRoom/Hubs/ReliableChatRoomHub.cs) for details.
+
+```C#
+///
+/// Hub method. Called everytime when client trys to log into hub with a new (or expired) session.
+///
+/// A random id of client device, used for notification service
+/// The username of client
+///
+public async Task EnterChatRoom(string deviceUuid, string username)
+
+///
+/// Hub method. Called everytime when client trys to ping the server to extend his/her session and stay alive.
+///
+/// A random id of client device, used for notification service (may be a new id)
+/// The username of client
+///
+public async Task TouchServer(string deviceUuid, string username)
+
+///
+/// Hub method. Called when client explicitly quits the chat room.
+///
+/// A random id of client device, used for notification service (may be a new id)
+/// The username of client
+///
+public async Task LeaveChatRoom(string deviceUuid, string username)
+
+///
+/// Hub method. Called when client sends a broadcast message.
+///
+/// The messageId generated by client side
+/// The client who send the message
+/// The message content. Can be string / binary object in base64
+/// Whether incoming message is an image message
+///
+public async Task OnBroadcastMessageReceived(string messageId, string sender, string payload, bool isImage)
+
+///
+/// Hub method. Called when client sends a private message.
+///
+/// The messageId generated by client side
+/// The client who sends the message
+/// The client who receives the message
+/// The message content. Can be string / binary object in base64
+/// Whether incoming message is an image message
+///
+public async Task OnPrivateMessageReceived(string messageId, string sender, string receiver, string payload, bool isImage)
+
+///
+/// Hub method. Called when client sends back an ACK on any message.
+///
+/// The unique id representing a ClientAck object
+/// The ack sender's username
+public void OnAckResponseReceived(string clientAckId, string username)
+
+///
+/// Hub method. Called when client broadcasts his/her read status on a specific message.
+/// Be advised. Only messages have status of read.
+///
+/// The messageId generated by client side
+/// The username of the client
+///
+public async Task OnReadResponseReceived(string messageId, string username)
+
+///
+/// Hub method. Called when client requests to pull his/her history message.
+///
+/// The username of the client
+/// The earliest message stored on the client. Any message
+/// after the untilTime will not be pulled
+///
+public async Task OnPullHistoryMessagesReceived(string username, long untilTime)
+
+///
+/// Hub method. Called when client wants to fetch the content of an image message.
+///
+///
+///
+///
+public async Task OnPullImageContentReceived(string username, string messageId)
+```
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom.sln b/samples/ReliableChatRoom/ReliableChatRoom.sln
new file mode 100644
index 00000000..ea8c9ff5
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom.sln
@@ -0,0 +1,31 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.30611.23
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReliableChatRoom", "ReliableChatRoom\ReliableChatRoom.csproj", "{0D2842A8-0045-4A79-8D38-02E7A080EF18}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReliableChatRoomUnitTest", "ReliableChatRoomUnitTest\ReliableChatRoomUnitTest.csproj", "{20E4380E-E0C9-4FAE-9A18-4A7A943F1F33}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {0D2842A8-0045-4A79-8D38-02E7A080EF18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0D2842A8-0045-4A79-8D38-02E7A080EF18}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0D2842A8-0045-4A79-8D38-02E7A080EF18}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0D2842A8-0045-4A79-8D38-02E7A080EF18}.Release|Any CPU.Build.0 = Release|Any CPU
+ {20E4380E-E0C9-4FAE-9A18-4A7A943F1F33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {20E4380E-E0C9-4FAE-9A18-4A7A943F1F33}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {20E4380E-E0C9-4FAE-9A18-4A7A943F1F33}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {20E4380E-E0C9-4FAE-9A18-4A7A943F1F33}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {E1480C04-B469-4CE4-8BE5-F476E4A16C87}
+ EndGlobalSection
+EndGlobal
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/.gitignore b/samples/ReliableChatRoom/ReliableChatRoom/.gitignore
new file mode 100644
index 00000000..d8074abd
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/.gitignore
@@ -0,0 +1,4 @@
+bin/
+obj/
+.vs/
+**.csproj.user
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Entities/ClientAck.cs b/samples/ReliableChatRoom/ReliableChatRoom/Entities/ClientAck.cs
new file mode 100644
index 00000000..16173e43
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Entities/ClientAck.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities
+{
+ ///
+ /// A class that stores information about client acks.
+ /// Also stores information that can be utilized by a
+ /// to decide whether resending messages and checking acks are necessary.
+ ///
+ public class ClientAck
+ {
+ // A unique ClientAck ID
+ public string ClientAckId { get; set; }
+
+ // Time that this specific instance of ClientAck has been retried
+ public int RetryCount { get; set; }
+
+ ///
+ public ClientAckResultEnum ClientAckResult { get; set; }
+
+ // Start time of a ClientAck.
+ // Resending policies are applied on the calculation results based on this field.
+ public DateTime ClientAckStartDateTime { get; set; }
+
+ // For which client message this ClientAck is waiting.
+ public Message ClientMessage { get; set; }
+
+ // Username of receivers
+ public List Receivers { get; set; }
+
+ public ClientAck(string clientAckId, DateTime startDateTime, Message message, List receivers)
+ {
+ this.ClientAckId = clientAckId;
+ this.RetryCount = 0;
+ this.ClientAckResult = ClientAckResultEnum.Waiting;
+ this.ClientAckStartDateTime = startDateTime;
+ this.ClientMessage = message;
+ this.Receivers = receivers;
+ }
+
+ ///
+ /// An operation that retries the ClientAck
+ ///
+ public void Retry()
+ {
+ this.RetryCount += 1;
+ this.ClientAckResult = ClientAckResultEnum.Waiting;
+ this.ClientAckStartDateTime = DateTime.UtcNow;
+ }
+
+ ///
+ /// An operation that fails the ClientAck
+ ///
+ public void Fail()
+ {
+ this.ClientAckResult = ClientAckResultEnum.Failure;
+ }
+
+ ///
+ /// An operation that times out the ClientAck
+ ///
+ public void TimeOut()
+ {
+ this.ClientAckResult = ClientAckResultEnum.TimeOut;
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Entities/ClientAckResultEnum.cs b/samples/ReliableChatRoom/ReliableChatRoom/Entities/ClientAckResultEnum.cs
new file mode 100644
index 00000000..f4fbf2d9
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Entities/ClientAckResultEnum.cs
@@ -0,0 +1,14 @@
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities
+{
+ ///
+ /// Defines an enum class representing possible states of a
+ ///
+ public enum ClientAckResultEnum
+ {
+ Waiting,
+ Success,
+ TimeOut,
+ Failure
+ }
+
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Entities/Message.cs b/samples/ReliableChatRoom/ReliableChatRoom/Entities/Message.cs
new file mode 100644
index 00000000..0d15de80
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Entities/Message.cs
@@ -0,0 +1,63 @@
+using Newtonsoft.Json;
+using System;
+using System.Runtime.Serialization;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities
+{
+ ///
+ /// Wrapper class of user messages
+ ///
+ public class Message
+ {
+ // String placeholder for the Receiver field of a Broadcast Message
+ public static readonly string BROADCAST_RECEIVER = "BCAST";
+
+ // String placeholder for the Sender field of a System Message
+ public static readonly string SYSTEM_SENDER = "SYS";
+
+ // A Uuid generated and sent by client. Server-side do not generate messageIds
+ public string MessageId { get; set; }
+
+ ///
+ public MessageTypeEnum Type { get; set; }
+
+ // Sender and Receiver of a message
+ public string Sender { get; set; }
+ public string Receiver { get; set; }
+
+ // Content of message. Can be either a text string or rich content represented by a Base64 string
+ public string Payload { get; set; }
+
+ [JsonIgnore]
+ public string ImagePayload { get; set; }
+
+ // Indicate whether it is an image message
+ public bool IsImage { get; set; }
+
+ // Indicate whether a private message is read
+ public bool IsRead { get; set; }
+
+ // The time when the broadcast message reaches the server are labeled as sendTime
+ public DateTime SendTime { get; set; }
+
+ // Constructor
+ public Message(string messageId, string sender, string receiver, string payload, bool isImage, bool isRead, MessageTypeEnum type, DateTime sendTime)
+ {
+ this.MessageId = messageId;
+ this.Type = type;
+ this.Sender = sender;
+ this.Receiver = receiver;
+ if (isImage)
+ {
+ this.ImagePayload = payload;
+ this.Payload = "";
+ } else
+ {
+ this.Payload = payload;
+ }
+ this.IsRead = isRead;
+ this.IsImage = isImage;
+ this.SendTime = sendTime;
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Entities/MessageTypeEnum.cs b/samples/ReliableChatRoom/ReliableChatRoom/Entities/MessageTypeEnum.cs
new file mode 100644
index 00000000..0a18be5e
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Entities/MessageTypeEnum.cs
@@ -0,0 +1,17 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities
+{
+ ///
+ /// Defines an enum class representing private messags, system message, and broadcast message
+ ///
+ public enum MessageTypeEnum
+ {
+ Private,
+ System,
+ Broadcast
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Entities/Session.cs b/samples/ReliableChatRoom/ReliableChatRoom/Entities/Session.cs
new file mode 100644
index 00000000..c4aca879
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Entities/Session.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities
+{
+ public class Session
+ {
+ // The username provided by the client (by calling EnterChatRoom in the Hub)
+ public string Username { get; set; }
+
+ // The SignalR connection id
+ public string ConnectionId { get; set; }
+
+ // The unique device id provided by the client (by calling EnterChatRoom in the Hub),
+ // for the purpose of notification pushing.
+ public string DeviceUuid { get; set; }
+
+ // Time when the client last touched (tried to refresh his/her session in the server)
+ public DateTime LastTouchedDateTime { get; set; }
+
+ /// >
+ public SessionTypeEnum SessionType { get; set; }
+
+ public Session(string username, string connectionId, string deviceUuid)
+ {
+ this.Username = username;
+ this.ConnectionId = connectionId;
+ this.DeviceUuid = deviceUuid;
+ this.LastTouchedDateTime = DateTime.UtcNow;
+ this.SessionType = SessionTypeEnum.Active;
+ }
+
+ ///
+ /// An operation that sets the session type to .
+ /// Usually called by an IUserHandler.
+ ///
+ public void Expire()
+ {
+ this.SessionType = SessionTypeEnum.Expired;
+ }
+
+ ///
+ /// An operation that sets the session type to ,
+ /// and then updates the connectionId and deviceUuid.
+ /// Usually called by an IUserHandler.
+ ///
+ /// The new connecton id
+ /// The new device uuid
+ public void Revive(string connectionId, string deviceUuid)
+ {
+ this.SessionType = SessionTypeEnum.Active;
+ this.ConnectionId = connectionId;
+ this.DeviceUuid = deviceUuid;
+ }
+
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Entities/SessionTypeEnum.cs b/samples/ReliableChatRoom/ReliableChatRoom/Entities/SessionTypeEnum.cs
new file mode 100644
index 00000000..22638dc2
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Entities/SessionTypeEnum.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities
+{
+ ///
+ /// Defines an enum class representing active session, and expired session
+ ///
+ public enum SessionTypeEnum
+ {
+ Active,
+ Expired
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Factory/IMessageFactory.cs b/samples/ReliableChatRoom/ReliableChatRoom/Factory/IMessageFactory.cs
new file mode 100644
index 00000000..ac97405f
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Factory/IMessageFactory.cs
@@ -0,0 +1,69 @@
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Factory
+{
+ public interface IMessageFactory
+ {
+ ///
+ /// Creates a according to the given username, action, and sendDate.
+ ///
+ /// Username of client
+ /// Can be either "left" or "join"
+ /// Time when client joined the chat room, i.e. when EnterChatRoom was called
+ /// A
+ Message CreateSystemMessage(string username, string action, DateTime sendDate);
+
+ ///
+ /// Creates a according to the given username, action, and sendDate.
+ ///
+ /// A Uuid generated and sent by client. Server-side do not generate messageIds
+ /// Sender of the message
+ /// Content of message. Can be either a text string or rich content represented by a Base64 string
+ /// The time when the broadcast message reaches the server are labeled as sendTime
+ /// A
+ Message CreateBroadcastMessage(string messageId, string sender, string payload, bool isImage, DateTime sendTime);
+
+ ///
+ /// Creates a according to the given username, action, and sendDate.
+ ///
+ /// A Uuid generated and sent by client. Server-side do not generate messageIds
+ /// Sender of the message
+ /// Sender of the message
+ /// Receiver of the message
+ /// The time when the broadcast message reaches the server are labeled as sendTime
+ /// A
+ Message CreatePrivateMessage(string messageId, string sender, string receiver, string payload, bool isImage, DateTime sendTime);
+
+ ///
+ /// Converts a list of from a json string.
+ ///
+ /// The json string from which a list of messages is created
+ /// The converted list of
+ List FromListJsonString(string jsonString);
+
+ ///
+ /// Converts an instance of from a json string.
+ ///
+ /// The json string from which an instance of message is created
+ /// The converted instance of
+ Message FromSingleJsonString(string jsonString);
+
+ ///
+ /// Converts a json string from a list of .
+ ///
+ /// The list of messages from which the json string is created
+ /// The converted json string
+ string ToListJsonString(List messages);
+
+ ///
+ /// Converts a json string from an instance of .
+ ///
+ /// The instance of message from which the json string is created
+ /// The converted json string
+ string ToSingleJsonString(Message message);
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Factory/MessageFactory.cs b/samples/ReliableChatRoom/ReliableChatRoom/Factory/MessageFactory.cs
new file mode 100644
index 00000000..8ad58382
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Factory/MessageFactory.cs
@@ -0,0 +1,71 @@
+using Azure.Storage.Blobs;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Factory
+{
+ public class MessageFactory : IMessageFactory
+ {
+ public Message CreateSystemMessage(string username, string action, DateTime sendTime)
+ {
+ return new Message(
+ Guid.NewGuid().ToString(),
+ Message.SYSTEM_SENDER, Message.BROADCAST_RECEIVER,
+ string.Format("{0} has {1} the chat", username, action),
+ false,
+ true,
+ MessageTypeEnum.System,
+ sendTime);
+ }
+
+ public Message CreateBroadcastMessage(string messageId, string sender, string payload, bool isImage, DateTime sendTime)
+ {
+ return new Message(
+ messageId,
+ sender, Message.BROADCAST_RECEIVER,
+ payload,
+ isImage,
+ true,
+ MessageTypeEnum.Broadcast,
+ sendTime);
+ }
+
+ public Message CreatePrivateMessage(string messageId, string sender, string receiver, string payload, bool isImage, DateTime sendTime)
+ {
+ return new Message(
+ messageId,
+ sender, receiver,
+ payload,
+ isImage,
+ false,
+ MessageTypeEnum.Private,
+ sendTime);
+ }
+
+ public List FromListJsonString(string jsonString)
+ {
+ List messages = (List) JsonConvert.DeserializeObject(jsonString, typeof(List));
+ return messages;
+ }
+
+ public Message FromSingleJsonString(string jsonString)
+ {
+ Message message = (Message) JsonConvert.DeserializeObject(jsonString, typeof(Message));
+ return message;
+ }
+
+ public string ToListJsonString(List messages)
+ {
+ return JsonConvert.SerializeObject(messages);
+ }
+
+ public string ToSingleJsonString(Message message)
+ {
+ return JsonConvert.SerializeObject(message);
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Handlers/ClientAckHandler.cs b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/ClientAckHandler.cs
new file mode 100644
index 00000000..7f9b44ad
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/ClientAckHandler.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Hubs;
+using Microsoft.AspNetCore.SignalR;
+using System.Linq;
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers
+{
+ public class ClientAckHandler : IClientAckHandler, IDisposable
+ {
+ private readonly ILogger _logger;
+
+ // HubContext used to send timed-out messages
+ private readonly IHubContext _hubContext;
+
+ // UserHandler used to query user information
+ private readonly IUserHandler _userHandler;
+
+ /// In memory storage of
+ private readonly ConcurrentDictionary _clientAcks = new ConcurrentDictionary();
+
+ /// Max timespan a ClientAck can be Waiting without being called with
+ private readonly TimeSpan _checkAckThreshold;
+
+ // Period of Timer checking the status of ClientAcks
+ private readonly TimeSpan _checkAckInterval;
+
+ // Max time to resend a un-acknowledged message
+ private readonly int _resendMessageThreshold;
+
+ // Period of Timer resending the timed-out messages
+ private readonly TimeSpan _resendMessageInterval;
+
+ // Timers for checking ClientAcks and resending messages
+ private readonly Timer _checkAckTimer;
+ private readonly Timer _resendMessageTimer;
+
+ // UNIX origin of time
+ private readonly DateTime _javaEpoch = new DateTime(1970, 1, 1);
+
+ public ClientAckHandler(
+ ILogger logger,
+ IHubContext hubContext,
+ IUserHandler userHandler)
+ : this(
+ checkAckThreshold: TimeSpan.FromMilliseconds(5000),
+ checkAckInterval: TimeSpan.FromMilliseconds(500),
+ resendMessageThreshold: 3,
+ resendMessageInterval: TimeSpan.FromMilliseconds(1000))
+ {
+ _logger = logger;
+ _hubContext = hubContext;
+ _userHandler = userHandler;
+ }
+
+ public ClientAckHandler(TimeSpan checkAckThreshold, TimeSpan checkAckInterval, int resendMessageThreshold, TimeSpan resendMessageInterval)
+ {
+ _checkAckThreshold = checkAckThreshold;
+ _checkAckInterval = checkAckInterval;
+ _resendMessageThreshold = resendMessageThreshold;
+ _resendMessageInterval = resendMessageInterval;
+ _checkAckTimer = new Timer(_ => CheckAcks(), state: null, dueTime: TimeSpan.FromMilliseconds(0), period: _checkAckInterval);
+ _resendMessageTimer = new Timer(_ => ResendTimeOutMessages(), state: null, dueTime: TimeSpan.FromMilliseconds(500), period: _resendMessageInterval);
+ }
+
+ public ClientAck CreateClientAck(Message message)
+ {
+ // Receivers involved in the ClientAck
+ List receivers;
+ if (message.Type == MessageTypeEnum.Broadcast)
+ {
+ // Everyone except the sender
+ receivers = new List(_userHandler.GetActiveSessions().Select(sess => sess.Username));
+ receivers.Remove(message.Sender);
+ } else
+ {
+ // Only the receiver
+ receivers = new List() { message.Receiver };
+ }
+
+ ClientAck clientAck = new ClientAck(Guid.NewGuid().ToString(), DateTime.UtcNow, message, receivers);
+ _clientAcks.TryAdd(clientAck.ClientAckId, clientAck);
+
+ return clientAck;
+ }
+
+ public void Ack(string id, string username)
+ {
+ if (_clientAcks.TryGetValue(id, out var clientAck))
+ {
+ clientAck.Receivers.Remove(username);
+ }
+ else
+ {
+ _logger.LogInformation("ClientAck id: {0} not found; sender: {1}.", id, username);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_checkAckTimer != null)
+ {
+ _checkAckTimer.Dispose();
+ _resendMessageTimer.Dispose();
+ }
+ }
+
+ private void CheckAcks()
+ {
+ foreach (ClientAck clientAck in _clientAcks.Values)
+ {
+ if (clientAck.ClientAckResult == ClientAckResultEnum.Waiting)
+ {
+ if (clientAck.Receivers.Count == 0)
+ {
+ clientAck.ClientAckResult = ClientAckResultEnum.Success;
+ } else
+ {
+ var elapsed = DateTime.UtcNow - clientAck.ClientAckStartDateTime;
+ if (elapsed > _checkAckThreshold)
+ {
+ _logger.LogInformation("Ack id: {0} time out", clientAck.ClientAckId);
+ clientAck.TimeOut();
+ }
+ }
+ }
+ }
+ }
+
+ private void ResendTimeOutMessages()
+ {
+ // Calculate timeout acks
+ var timeOutClientAcks = _clientAcks.Values.Where(ack => ack.ClientAckResult == ClientAckResultEnum.TimeOut).ToList();
+ if (timeOutClientAcks.Count == 0)
+ {
+ // No messages need to resend
+ return;
+ }
+
+
+ foreach (ClientAck clientAck in timeOutClientAcks)
+ {
+ // Only resend acks within threshold
+ if (clientAck.RetryCount < _resendMessageThreshold)
+ {
+ clientAck.Retry();
+ _logger.LogInformation("Retry {0}: {1}", clientAck.RetryCount, clientAck.ClientAckId);
+ Message clientMessage = clientAck.ClientMessage;
+ if (clientAck.ClientMessage.Type == MessageTypeEnum.Broadcast)
+ {
+ ResendBroadcastMessage(clientMessage, clientAck.ClientAckId);
+ }
+ else if (clientAck.ClientMessage.Type == MessageTypeEnum.Private)
+ {
+ ResendPrivateMessage(clientMessage, clientAck.ClientAckId);
+ }
+ }
+ // Acks being retried more than threshold are set to failure
+ else
+ {
+ clientAck.Fail();
+ }
+ }
+ }
+
+ private void ResendBroadcastMessage(Message broadcastMessage, string ackId)
+ {
+ string senderConnectionId = _userHandler.GetUserSession(broadcastMessage.Sender).ConnectionId;
+ _logger.LogInformation("ResendBroadcastMessage: sender connectionid: {0}", senderConnectionId);
+ _hubContext.Clients.AllExcept(senderConnectionId)
+ .SendAsync("receiveBroadcastMessage",
+ broadcastMessage.MessageId,
+ broadcastMessage.Sender,
+ broadcastMessage.Receiver,
+ broadcastMessage.Payload,
+ broadcastMessage.IsImage,
+ (broadcastMessage.SendTime - _javaEpoch).Ticks / TimeSpan.TicksPerMillisecond,
+ ackId);
+ }
+
+ private void ResendPrivateMessage(Message privateMessage, string ackId)
+ {
+ Session receiverSession = _userHandler.GetUserSession(privateMessage.Receiver);
+
+ if (receiverSession != null)
+ {
+ string receiverConnectionId = receiverSession.ConnectionId;
+
+ _logger.LogInformation("ResendPrivateMessage: receiver connectionid: {0}", receiverConnectionId);
+ _hubContext.Clients.Client(receiverConnectionId)
+ .SendAsync("receivePrivateMessage",
+ privateMessage.MessageId,
+ privateMessage.Sender,
+ privateMessage.Receiver,
+ privateMessage.Payload,
+ privateMessage.IsImage,
+ (privateMessage.SendTime - _javaEpoch).Ticks / TimeSpan.TicksPerMillisecond,
+ ackId);
+ }
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Handlers/IClientAckHandler.cs b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/IClientAckHandler.cs
new file mode 100644
index 00000000..b54236ed
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/IClientAckHandler.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers
+{
+ ///
+ /// A class that handles the management.
+ ///
+ public interface IClientAckHandler
+ {
+ ///
+ /// Creates a according to a
+ ///
+ /// The message that the Client Ack is waiting for.
+ /// The created ClientAck for the message.
+ ClientAck CreateClientAck(Message message);
+
+ ///
+ /// Ack and complete the with a clientAckId.
+ ///
+ /// The unique id that specifies a
+ /// The ack sender's username
+ void Ack(string clientAckId, string username);
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Handlers/INotificationHandler.cs b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/INotificationHandler.cs
new file mode 100644
index 00000000..9f1f52f2
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/INotificationHandler.cs
@@ -0,0 +1,30 @@
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers
+{
+ ///
+ /// A class that handles the notification pushing (Possibly with the help of Azure Notification Hub)
+ ///
+ public interface INotificationHandler
+ {
+ ///
+ /// Send private message notification.
+ /// Only send to receiver.
+ ///
+ /// The message that cause the notification pushing
+ ///
+ Task SendPrivateNotification(Message privateMessage);
+
+ ///
+ /// Send broadcast message notification.
+ /// Send to everybody with an active session but the sender.
+ ///
+ /// The message that cause the notification pushing
+ ///
+ Task SendBroadcastNotification(Message broadcastMessage);
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Handlers/IUserHandler.cs b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/IUserHandler.cs
new file mode 100644
index 00000000..54fac12a
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/IUserHandler.cs
@@ -0,0 +1,54 @@
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers
+{
+
+ ///
+ /// Defines behaviors of a user session manager service
+ ///
+ public interface IUserHandler
+ {
+ ///
+ /// Registers client's username, connectionId, and deviceUuid into alive sessions
+ ///
+ /// Client's username
+ /// Client's connetion id in the current context
+ /// Client-generated unique id for device
+ /// A user session
+ Session Login(string username, string connectionId, string deviceUuid);
+
+ ///
+ /// Refreshes/extends/keeps alive user session
+ /// Also updates connectionId c(if changed)
+ ///
+ /// Client's username
+ /// Client's connetion id in the current context
+ /// Client-generated unique id for device
+ /// If touch was a success, return the DateTime of the touch. Otherwise, return default DateTime.
+ DateTime Touch(string username, string connectionId, string deviceUuid);
+
+ ///
+ /// Unregisters a client
+ ///
+ /// Client's username
+ /// A user session
+ Session Logout(string username);
+
+ ///
+ /// Returns a session of the provided username
+ ///
+ /// Client's username
+ /// A user session
+ Session GetUserSession(string username);
+
+ ///
+ /// Returns a collection of active user sessions
+ ///
+ /// A collection of user sessions
+ ICollection GetActiveSessions();
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Handlers/NotificationHandler.cs b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/NotificationHandler.cs
new file mode 100644
index 00000000..e9f6c5b5
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/NotificationHandler.cs
@@ -0,0 +1,62 @@
+using Microsoft.Azure.NotificationHubs;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers
+{
+ public class NotificationHandler : INotificationHandler
+ {
+ private readonly ILogger _logger;
+
+ // User handler for getting session info
+ private readonly IUserHandler _userHandler;
+
+ // Notification Hub Client for sending notification
+ private readonly NotificationHubClient _notificationHubClient;
+
+ // Format string for notification payload
+ private readonly string _formatString = @"{{ ""data"" : {{ ""sender"" : ""{0}"", ""text"" : ""{1}"" }} }}";
+
+ public NotificationHandler(
+ ILogger logger,
+ IUserHandler userHandler,
+ string connectionString,
+ string hubName)
+ {
+ _logger = logger;
+ _userHandler = userHandler;
+ _notificationHubClient = NotificationHubClient.CreateClientFromConnectionString(connectionString, hubName);
+ }
+
+ public async Task SendBroadcastNotification(Message broadcastMessage)
+ {
+ Session senderSession = _userHandler.GetUserSession(broadcastMessage.Sender);
+ if (senderSession != null) // Though sender session is very unlikely to be null
+ {
+ string jsonPayload = string.Format(_formatString, broadcastMessage.Sender, broadcastMessage.Payload);
+ // TagExpression of "not USER_TAG", meaning sending to everyone but USER_TAG
+ string targetTagExpression = string.Format("! {0}", _userHandler.GetUserSession(broadcastMessage.Sender).DeviceUuid);
+
+ _logger.LogInformation("Send broadcast notification from {0}", broadcastMessage.Sender);
+ await _notificationHubClient.SendFcmNativeNotificationAsync(jsonPayload, targetTagExpression);
+ }
+ }
+
+ public async Task SendPrivateNotification(Message privateMessage)
+ {
+ Session receiverSession = _userHandler.GetUserSession(privateMessage.Receiver);
+ if (receiverSession != null) // Only happens when send to a non-existing receiver
+ {
+ string jsonPayload = string.Format(_formatString, privateMessage.Sender, privateMessage.Payload);
+ string targetTagExpression = string.Format("{0}", receiverSession.DeviceUuid);
+
+ _logger.LogInformation("Send private notification from {0} to {1}", privateMessage.Sender, privateMessage.Receiver);
+ await _notificationHubClient.SendFcmNativeNotificationAsync(jsonPayload, targetTagExpression);
+ }
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Handlers/UserHandler.cs b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/UserHandler.cs
new file mode 100644
index 00000000..e75f08e9
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Handlers/UserHandler.cs
@@ -0,0 +1,138 @@
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Hubs;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Threading;
+
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers
+{
+ public class UserHandler : IUserHandler, IDisposable
+ {
+ private readonly ILogger _logger;
+
+ /// In memory storage of user
+ private readonly ConcurrentDictionary _sessionTable =
+ new ConcurrentDictionary();
+
+ // UNIX origin of time
+ private readonly DateTime _defaultDateTime = new DateTime(1970, 1, 1);
+
+ /// Max time a user can keep his session alive without calling
+ private readonly TimeSpan _sessionExpireThreshold = TimeSpan.FromSeconds(100);
+
+ // Period of Timer checking the session
+ private readonly TimeSpan _sessionCheckingInterval = TimeSpan.FromSeconds(100);
+
+ // Timer checking the session
+ private readonly Timer _sessionCheckingTimer;
+
+ public UserHandler(ILogger logger)
+ {
+ _logger = logger;
+ _sessionCheckingTimer = new Timer(_ => CheckSession(), state: null, dueTime: TimeSpan.FromMilliseconds(0), period: _sessionCheckingInterval);
+ }
+
+ public void Dispose()
+ {
+ _sessionCheckingTimer.Dispose();
+ }
+
+ public Session GetUserSession(string username)
+ {
+ bool hasUser = _sessionTable.TryGetValue(username, out Session storedSession);
+ if (hasUser)
+ {
+ return storedSession;
+ }
+ return null;
+ }
+
+ public ICollection GetActiveSessions()
+ {
+ ICollection activeSessions = new List();
+ foreach (Session session in _sessionTable.Values)
+ {
+ if (session.SessionType == SessionTypeEnum.Active)
+ {
+ activeSessions.Add(session);
+ }
+ }
+ return activeSessions;
+ }
+
+ public Session Login(string username, string connectionId, string deviceUuid)
+ {
+ bool isStoredSession = _sessionTable.TryGetValue(username, out Session storedSession);
+ if (isStoredSession)
+ {
+ // If session exists update the connectionId and deviceUuid
+ storedSession.Revive(connectionId, deviceUuid);
+ return storedSession;
+ } else
+ {
+ // Otherwise, create a new Session instance
+ Session session = new Session(username, connectionId, deviceUuid);
+ return _sessionTable.AddOrUpdate(username, session, (k, v) => session);
+ }
+ }
+
+ public DateTime Touch(string username, string connectionId, string deviceUuid)
+ {
+ bool isStoredSession = _sessionTable.TryGetValue(username, out Session storedSession);
+
+ if (!isStoredSession)
+ {
+ return _defaultDateTime;
+ }
+
+ if (storedSession.SessionType == SessionTypeEnum.Expired) // You cannot touch an expired session
+ {
+ return _defaultDateTime;
+ }
+
+ if (!connectionId.Equals(storedSession.ConnectionId)) // ConnectionIds between two continuous touches changed
+ {
+ _logger.LogInformation("Touch username: {0}\nconnectionId old: {1}\nconnectionId new: {2}", username, storedSession.ConnectionId, connectionId);
+ // Update connectionId
+ storedSession.ConnectionId = connectionId;
+ }
+
+ storedSession.LastTouchedDateTime = DateTime.UtcNow;
+
+ return storedSession.LastTouchedDateTime;
+ }
+
+ public Session Logout(string username)
+ {
+ bool removalSucceeded = _sessionTable.TryRemove(username, out Session removedSession);
+ if (removalSucceeded)
+ {
+ return removedSession;
+ }
+ return null;
+ }
+
+ ///
+ /// Called by Timer to check sessions.
+ ///
+ private void CheckSession()
+ {
+ foreach (var pair in _sessionTable) {
+ Session session = pair.Value;
+ if (session.SessionType == SessionTypeEnum.Active)
+ {
+ var elapsed = DateTime.UtcNow - session.LastTouchedDateTime;
+ if (elapsed > _sessionExpireThreshold)
+ {
+ _logger.LogInformation("Session username: {0} time out. Force expire.", session.Username);
+ session.Expire();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Hubs/ReliableChatRoomHub.cs b/samples/ReliableChatRoom/ReliableChatRoom/Hubs/ReliableChatRoomHub.cs
new file mode 100644
index 00000000..abf93fbd
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Hubs/ReliableChatRoomHub.cs
@@ -0,0 +1,386 @@
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Azure.Documents;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Factory;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Storage;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices.WindowsRuntime;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Hubs
+{
+ public class ReliableChatRoomHub : Hub
+ {
+ private readonly ILogger _logger;
+
+ private readonly IUserHandler _userHandler;
+ private readonly IMessageStorage _messageStorage;
+ private readonly IMessageFactory _messageFactory;
+ private readonly IClientAckHandler _clientAckHandler;
+ private readonly INotificationHandler _notificationHandler;
+
+ private readonly DateTime _defaultDateTime = new DateTime(1970, 1, 1);
+
+
+ public ReliableChatRoomHub(
+ ILogger logger,
+ IUserHandler userHandler,
+ IMessageStorage messageStorage,
+ IMessageFactory messageFactory,
+ IClientAckHandler clientAckHandler,
+ INotificationHandler notificationHandler)
+ {
+ _logger = logger;
+ _userHandler = userHandler;
+ _messageStorage = messageStorage;
+ _messageFactory = messageFactory;
+ _clientAckHandler = clientAckHandler;
+ _notificationHandler = notificationHandler;
+ }
+
+ ///
+ /// Hub method. Called everytime when client trys to log into hub with a new (or expired) session.
+ ///
+ /// A random id of client device, used for notification service
+ /// The username of client
+ ///
+ public async Task EnterChatRoom(string deviceUuid, string username)
+ {
+ _logger.LogInformation("EnterChatRoom device: {0} username: {1}", deviceUuid, username);
+
+ // Try to store user login information (ConnectionId & deviceUuid)
+ Session session = _userHandler.Login(username, Context.ConnectionId, deviceUuid);
+
+ // If login was successful, broadcast the system message
+ if (session != null)
+ {
+ Message loginMessage = _messageFactory.CreateSystemMessage(username, "joined", DateTime.UtcNow);
+ // Do not store system messages. Directly send them out.
+ await SendSystemMessage(loginMessage);
+ return "success";
+ } else
+ {
+ return "failure";
+ }
+ }
+
+ ///
+ /// Hub method. Called everytime when client trys to ping the server to extend his/her session and stay alive.
+ ///
+ /// A random id of client device, used for notification service (may be a new id)
+ /// The username of client
+ ///
+ public async Task TouchServer(string deviceUuid, string username)
+ {
+ DateTime touchedDateTime = _userHandler.Touch(username, Context.ConnectionId, deviceUuid);
+ if (touchedDateTime == _defaultDateTime) // Session either does not exist or has expired
+ {
+ // Force the client to expire session and re-login
+ await Clients.Caller.SendAsync("expireSession", true);
+ }
+ }
+
+ ///
+ /// Hub method. Called when client explicitly quits the chat room.
+ ///
+ /// A random id of client device, used for notification service (may be a new id)
+ /// The username of client
+ ///
+ public async Task LeaveChatRoom(string deviceUuid, string username)
+ {
+ _logger.LogInformation("LeaveChatRoom username: {0}", username);
+
+ // Do not care about logout result.
+ Session session = _userHandler.Logout(username);
+
+ // Broadcast the system message.
+ Message logoutMessage = _messageFactory.CreateSystemMessage(username, "left", DateTime.UtcNow);
+
+ // Do not store system messages. Directly send them out.
+ await SendSystemMessage(logoutMessage);
+
+ return "success";
+ }
+
+ ///
+ /// Hub method. Called when client sends a broadcast message.
+ ///
+ /// The messageId generated by client side
+ /// The client who send the message
+ /// The message content. Can be string / binary object in base64
+ /// Whether incoming message is an image message
+ ///
+ public async Task OnBroadcastMessageReceived(string messageId, string sender, string payload, bool isImage)
+ {
+ _logger.LogInformation("OnBroadcastMessageReceived {0} {1} payload size={2}", messageId, sender, payload.Length);
+
+ // Create message
+ Message message = _messageFactory.CreateBroadcastMessage(messageId, sender, payload, isImage, DateTime.UtcNow);
+
+ // Send back server ack without waiting for method result
+ long receivedTimeInLong = CSharpDateTimeToJavaLong(message.SendTime);
+ _ = Clients.Client(Context.ConnectionId).SendAsync("serverAck", message.MessageId, receivedTimeInLong);
+
+ // Try to store the message
+ bool success = await _messageStorage.TryStoreMessageAsync(message);
+
+ // Only send messages out when storage was a success
+ if (success)
+ {
+ await SendBroadCastMessage(message);
+ }
+ }
+
+ ///
+ /// Hub method. Called when client sends a private message.
+ ///
+ /// The messageId generated by client side
+ /// The client who sends the message
+ /// The client who receives the message
+ /// The message content. Can be string / binary object in base64
+ /// Whether incoming message is an image message
+ ///
+ public async Task OnPrivateMessageReceived(string messageId, string sender, string receiver, string payload, bool isImage)
+ {
+
+ _logger.LogInformation("OnPrivateMessageReceive {0} {1} {2} payload size={3}", messageId, sender, receiver, payload.Length);
+
+ // Create message and send back server ack
+ Message message = _messageFactory.CreatePrivateMessage(messageId, sender, receiver, payload, isImage, DateTime.UtcNow);
+
+ // Send back server ack without waiting for result
+ long receivedTimeInLong = CSharpDateTimeToJavaLong(message.SendTime);
+ _ = Clients.Client(Context.ConnectionId).SendAsync("serverAck", message.MessageId, receivedTimeInLong);
+
+ // Try to store the message
+ bool success = await _messageStorage.TryStoreMessageAsync(message);
+
+ // Only send messages out when storage was a success
+ if (success)
+ {
+ await SendPrivateMessage(message);
+ }
+ }
+
+ ///
+ /// Hub method. Called when client sends back an ACK on any message.
+ ///
+ /// The unique id representing a ClientAck object
+ /// The ack sender's username
+ public void OnAckResponseReceived(string clientAckId, string username)
+ {
+ _logger.LogInformation("OnAckResponseReceived clientAckId: {0}", clientAckId);
+
+ // Complete the waiting client ack object
+ _clientAckHandler.Ack(clientAckId, username);
+ }
+
+ ///
+ /// Hub method. Called when client broadcasts his/her read status on a specific message.
+ /// Be advised. Only messages have status of read.
+ ///
+ /// The messageId generated by client side
+ /// The username of the client
+ ///
+ public async Task OnReadResponseReceived(string messageId, string username)
+ {
+ _logger.LogInformation(string.Format("OnReadResponseReceived messageId: {0}; username: {1}", messageId, username));
+
+ // Try to set read and store the message
+ Message message = await _messageStorage.TryFetchMessageById(messageId);
+ message.IsRead = true;
+ bool success = await _messageStorage.TryUpdateMessageAsync(message);
+
+ // Only send messages out when storage was a success
+ if (success)
+ {
+ // Broadcast message read by user
+ await Clients.Client(_userHandler.GetUserSession(message.Sender).ConnectionId)
+ .SendAsync("clientRead", messageId, username);
+ }
+ }
+
+ ///
+ /// Hub method. Called when client requests to pull his/her history message.
+ ///
+ /// The username of the client
+ /// The earliest message stored on the client. Any message
+ /// after the untilTime will not be pulled
+ ///
+ public async Task OnPullHistoryMessagesReceived(string username, long untilTime)
+ {
+ _logger.LogInformation(string.Format("OnPullHistoryMessageReceived username: {0}; until: {1}", username, untilTime));
+
+ // Convert java base client time to C# DateTime object
+ var untilDateTime = JavaLongToCSharpDateTime(untilTime);
+
+ // Fetch history from message storage. After done, send them back with a callback method SendHistoryMessages.
+ List historyMessages = new List();
+ bool success = await _messageStorage.TryFetchHistoryMessageAsync(username, untilDateTime, historyMessages);
+
+ // Only send messages out when storage was a success
+ if (success)
+ {
+ await SendHistoryMessages(historyMessages);
+ }
+ }
+
+ ///
+ /// Hub method. Called when client wants to fetch the content of an image message.
+ ///
+ ///
+ ///
+ ///
+ public async Task OnPullImageContentReceived(string username, string messageId)
+ {
+ _logger.LogInformation(string.Format("OnPullImageContentReceived username: {0}; messageId: {1}", username, messageId));
+
+ string imagePayload = await _messageStorage.TryFetchImageContentAsync(messageId);
+
+ await Clients.Client(_userHandler.GetUserSession(username).ConnectionId).SendAsync("receiveImageContent", messageId, imagePayload);
+ }
+
+ ///
+ /// Utility method. Sends the passed systemMessage.
+ ///
+ /// System message to send
+ ///
+ private async Task SendSystemMessage(Message systemMessage)
+ {
+ // Broadcast to all other users
+ await Clients.All.SendAsync("receiveSystemMessage",
+ systemMessage.MessageId,
+ systemMessage.Payload,
+ CSharpDateTimeToJavaLong(systemMessage.SendTime));
+ }
+
+ ///
+ /// Utility method. Sends broadcast message to clients other than sender
+ ///
+ /// Broadcast message to send
+ ///
+ /// An Async Task of bool result.
+ /// true - Callback was success
+ /// false - Callback was failure
+ ///
+ private async Task SendBroadCastMessage(Message broadcastMessage)
+ {
+ // Create a client ack
+ var clientAck = _clientAckHandler.CreateClientAck(broadcastMessage);
+
+ // Send notification first and do not block for result
+ _ = _notificationHandler.SendBroadcastNotification(broadcastMessage);
+
+ // Broadcast to all other users
+ try
+ {
+ await Clients.AllExcept(_userHandler.GetUserSession(broadcastMessage.Sender).ConnectionId)
+ .SendAsync("receiveBroadcastMessage",
+ broadcastMessage.MessageId,
+ broadcastMessage.Sender,
+ broadcastMessage.Receiver,
+ broadcastMessage.Payload,
+ broadcastMessage.IsImage,
+ CSharpDateTimeToJavaLong(broadcastMessage.SendTime),
+ clientAck.ClientAckId);
+ } catch (Exception ex)
+ {
+ _logger.LogError(ex.Message);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Utility method. Sends private message to the receiver client.
+ ///
+ /// Private message to send
+ /// IHubContext to call client methods
+ ///
+ /// An Async Task of bool result.
+ /// true - Callback was success
+ /// false - Callback was failure
+ ///
+ private async Task SendPrivateMessage(Message privateMessage)
+ {
+ // Create a client ack
+ var clientAck = _clientAckHandler.CreateClientAck(privateMessage);
+
+ // Send notification first and do not block for result
+ _ = _notificationHandler.SendPrivateNotification(privateMessage);
+
+ try
+ {
+ // Send to receiver then
+ await Clients.Client(_userHandler.GetUserSession(privateMessage.Receiver).ConnectionId)
+ .SendAsync("receivePrivateMessage",
+ privateMessage.MessageId,
+ privateMessage.Sender,
+ privateMessage.Receiver,
+ privateMessage.Payload,
+ privateMessage.IsImage,
+ CSharpDateTimeToJavaLong(privateMessage.SendTime),
+ clientAck.ClientAckId);
+ } catch (Exception ex)
+ {
+ _logger.LogError(ex.Message);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Utility method. Sends list of history messages to the requesting client.
+ ///
+ /// List of history messages to send back
+ ///
+ /// An Async Task of bool result.
+ /// true - Callback was success
+ /// false - Callback was failure
+ ///
+ private async Task SendHistoryMessages(List historyMessages)
+ {
+ try
+ {
+ // Convert list of history messages to jsonString, then send to the client.
+ _logger.LogInformation("SendHistoryMessages");
+ await Clients.Client(Context.ConnectionId)
+ .SendAsync("receiveHistoryMessages", _messageFactory.ToListJsonString(historyMessages));
+ } catch (Exception ex)
+ {
+ _logger.LogError(ex.Message);
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Utility method. Converts java milliseconds in long to C# DateTime object
+ ///
+ /// Java milliseconds in long
+ ///
+ private DateTime JavaLongToCSharpDateTime(long milliseconds)
+ {
+ long ticks = milliseconds * TimeSpan.TicksPerMillisecond + _defaultDateTime.Ticks;
+ return new DateTime(ticks);
+ }
+
+ ///
+ /// Utility method. Converts C# DateTime object to java milliseconds in long
+ ///
+ ///
+ ///
+ private long CSharpDateTimeToJavaLong(DateTime dateTime)
+ {
+ return (dateTime - _defaultDateTime).Ticks / TimeSpan.TicksPerMillisecond;
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Program.cs b/samples/ReliableChatRoom/ReliableChatRoom/Program.cs
new file mode 100644
index 00000000..cc200e11
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Program.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.AspNetCore;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom
+{
+ public class Program
+ {
+ public static void Main(string[] args)
+ {
+ CreateWebHostBuilder(args).Build().Run();
+ }
+
+ public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
+ WebHost.CreateDefaultBuilder(args)
+ .ConfigureLogging(logging =>
+ {
+ logging.ClearProviders();
+ logging.AddConsole();
+ logging.AddAzureWebAppDiagnostics();
+ })
+ .UseStartup();
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/profile.arm.json b/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/profile.arm.json
new file mode 100644
index 00000000..906a5b9a
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/profile.arm.json
@@ -0,0 +1,113 @@
+{
+ "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "metadata": {
+ "_dependencyType": "appService.windows"
+ },
+ "parameters": {
+ "resourceGroupName": {
+ "type": "string",
+ "defaultValue": "signalr-sample-demo",
+ "metadata": {
+ "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
+ }
+ },
+ "resourceGroupLocation": {
+ "type": "string",
+ "defaultValue": "westus",
+ "metadata": {
+ "description": "Location of the resource group. Resource groups could have different location than resources, however by default we use API versions from latest hybrid profile which support all locations for resource types we support."
+ }
+ },
+ "resourceName": {
+ "type": "string",
+ "defaultValue": "MobileChatRoomApp",
+ "metadata": {
+ "description": "Name of the main resource to be created by this template."
+ }
+ },
+ "resourceLocation": {
+ "type": "string",
+ "defaultValue": "[parameters('resourceGroupLocation')]",
+ "metadata": {
+ "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
+ }
+ }
+ },
+ "variables": {
+ "appServicePlan_name": "[concat('Plan', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
+ "appServicePlan_ResourceId": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Web/serverFarms/', variables('appServicePlan_name'))]"
+ },
+ "resources": [
+ {
+ "type": "Microsoft.Resources/resourceGroups",
+ "name": "[parameters('resourceGroupName')]",
+ "location": "[parameters('resourceGroupLocation')]",
+ "apiVersion": "2019-10-01"
+ },
+ {
+ "type": "Microsoft.Resources/deployments",
+ "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat(parameters('resourceName'), subscription().subscriptionId)))]",
+ "resourceGroup": "[parameters('resourceGroupName')]",
+ "apiVersion": "2019-10-01",
+ "dependsOn": [
+ "[parameters('resourceGroupName')]"
+ ],
+ "properties": {
+ "mode": "Incremental",
+ "template": {
+ "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "resources": [
+ {
+ "location": "[parameters('resourceLocation')]",
+ "name": "[parameters('resourceName')]",
+ "type": "Microsoft.Web/sites",
+ "apiVersion": "2015-08-01",
+ "tags": {
+ "[concat('hidden-related:', variables('appServicePlan_ResourceId'))]": "empty"
+ },
+ "dependsOn": [
+ "[variables('appServicePlan_ResourceId')]"
+ ],
+ "kind": "app",
+ "properties": {
+ "name": "[parameters('resourceName')]",
+ "kind": "app",
+ "httpsOnly": true,
+ "reserved": false,
+ "serverFarmId": "[variables('appServicePlan_ResourceId')]",
+ "siteConfig": {
+ "metadata": [
+ {
+ "name": "CURRENT_STACK",
+ "value": "dotnetcore"
+ }
+ ]
+ }
+ },
+ "identity": {
+ "type": "SystemAssigned"
+ }
+ },
+ {
+ "location": "[parameters('resourceLocation')]",
+ "name": "[variables('appServicePlan_name')]",
+ "type": "Microsoft.Web/serverFarms",
+ "apiVersion": "2015-08-01",
+ "sku": {
+ "name": "S1",
+ "tier": "Standard",
+ "family": "S",
+ "size": "S1"
+ },
+ "properties": {
+ "name": "[variables('appServicePlan_name')]"
+ }
+ }
+ ]
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/signalr1.arm.json b/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/signalr1.arm.json
new file mode 100644
index 00000000..3570aaaf
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/signalr1.arm.json
@@ -0,0 +1,71 @@
+{
+ "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "parameters": {
+ "resourceGroupName": {
+ "type": "string",
+ "defaultValue": "signalr-sample-demo",
+ "metadata": {
+ "_parameterType": "resourceGroup",
+ "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
+ }
+ },
+ "resourceGroupLocation": {
+ "type": "string",
+ "defaultValue": "westus",
+ "metadata": {
+ "_parameterType": "location",
+ "description": "Location of the resource group. Resource groups could have different location than resources."
+ }
+ },
+ "resourceLocation": {
+ "type": "string",
+ "defaultValue": "[parameters('resourceGroupLocation')]",
+ "metadata": {
+ "_parameterType": "location",
+ "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
+ }
+ }
+ },
+ "resources": [
+ {
+ "type": "Microsoft.Resources/resourceGroups",
+ "name": "[parameters('resourceGroupName')]",
+ "location": "[parameters('resourceGroupLocation')]",
+ "apiVersion": "2019-10-01"
+ },
+ {
+ "type": "Microsoft.Resources/deployments",
+ "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('mobilechatroom', subscription().subscriptionId)))]",
+ "resourceGroup": "[parameters('resourceGroupName')]",
+ "apiVersion": "2019-10-01",
+ "dependsOn": [
+ "[parameters('resourceGroupName')]"
+ ],
+ "properties": {
+ "mode": "Incremental",
+ "template": {
+ "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "resources": [
+ {
+ "sku": {
+ "name": "Standard_S1",
+ "tier": "Standard",
+ "size": "S1",
+ "capacity": 2
+ },
+ "location": "[parameters('resourceLocation')]",
+ "name": "mobilechatroom",
+ "type": "Microsoft.SignalRService/SignalR",
+ "apiVersion": "2018-10-01"
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "metadata": {
+ "_dependencyType": "signalr.azure"
+ }
+}
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/storage1.arm.json b/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/storage1.arm.json
new file mode 100644
index 00000000..b66c28c6
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Properties/ServiceDependencies/MobileChatRoomApp - Web Deploy/storage1.arm.json
@@ -0,0 +1,70 @@
+{
+ "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "parameters": {
+ "resourceGroupName": {
+ "type": "string",
+ "defaultValue": "signalr-sample-demo",
+ "metadata": {
+ "_parameterType": "resourceGroup",
+ "description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
+ }
+ },
+ "resourceGroupLocation": {
+ "type": "string",
+ "defaultValue": "westus",
+ "metadata": {
+ "_parameterType": "location",
+ "description": "Location of the resource group. Resource groups could have different location than resources."
+ }
+ },
+ "resourceLocation": {
+ "type": "string",
+ "defaultValue": "[parameters('resourceGroupLocation')]",
+ "metadata": {
+ "_parameterType": "location",
+ "description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
+ }
+ }
+ },
+ "resources": [
+ {
+ "type": "Microsoft.Resources/resourceGroups",
+ "name": "[parameters('resourceGroupName')]",
+ "location": "[parameters('resourceGroupLocation')]",
+ "apiVersion": "2019-10-01"
+ },
+ {
+ "type": "Microsoft.Resources/deployments",
+ "name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('mobilechatroom', subscription().subscriptionId)))]",
+ "resourceGroup": "[parameters('resourceGroupName')]",
+ "apiVersion": "2019-10-01",
+ "dependsOn": [
+ "[parameters('resourceGroupName')]"
+ ],
+ "properties": {
+ "mode": "Incremental",
+ "template": {
+ "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
+ "contentVersion": "1.0.0.0",
+ "resources": [
+ {
+ "sku": {
+ "name": "Standard_RAGRS",
+ "tier": "Standard"
+ },
+ "kind": "StorageV2",
+ "name": "mobilechatroom",
+ "type": "Microsoft.Storage/storageAccounts",
+ "location": "[parameters('resourceLocation')]",
+ "apiVersion": "2017-10-01"
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "metadata": {
+ "_dependencyType": "storage.azure"
+ }
+}
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Properties/launchSettings.json b/samples/ReliableChatRoom/ReliableChatRoom/Properties/launchSettings.json
new file mode 100644
index 00000000..53d29a73
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Properties/launchSettings.json
@@ -0,0 +1,18 @@
+{
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:63606/",
+ "sslPort": 0
+ }
+ },
+ "profiles": {
+ "ChatRoom": {
+ "commandName": "Project",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.MobileChatRoomApp - Web Deploy.json b/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.MobileChatRoomApp - Web Deploy.json
new file mode 100644
index 00000000..c4166c10
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.MobileChatRoomApp - Web Deploy.json
@@ -0,0 +1,16 @@
+{
+ "dependencies": {
+ "signalr1": {
+ "resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/Microsoft.SignalRService/SignalR/mobilechatroom",
+ "type": "signalr.azure",
+ "connectionId": "Azure__SignalR__ConnectionString",
+ "secretStore": "AzureAppSettings"
+ },
+ "storage1": {
+ "resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/Microsoft.Storage/storageAccounts/mobilechatroom",
+ "type": "storage.azure",
+ "connectionId": "StorageAccountConnectionString",
+ "secretStore": "AzureAppSettings"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.MobileChatRoomApp - Web Deploy.json.user b/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.MobileChatRoomApp - Web Deploy.json.user
new file mode 100644
index 00000000..2af6b5a4
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.MobileChatRoomApp - Web Deploy.json.user
@@ -0,0 +1,34 @@
+{
+ "dependencies": {
+ "storage1": {
+ "restored": true,
+ "restoreTime": "2020-12-03T02:21:58.2909543Z"
+ },
+ "signalr1": {
+ "restored": true,
+ "restoreTime": "2020-12-03T01:59:12.5383189Z"
+ }
+ },
+ "parameters": {
+ "storage1.resourceGroupName": {
+ "Name": "storage1.resourceGroupName",
+ "Type": "resourceGroup",
+ "Value": "signalr-sample-demo"
+ },
+ "signalr1.resourceGroupName": {
+ "Name": "signalr1.resourceGroupName",
+ "Type": "resourceGroup",
+ "Value": "signalr-sample-demo"
+ },
+ "storage1.subscriptionId": {
+ "Name": "storage1.subscriptionId",
+ "Type": "subscription",
+ "Value": "9caf2a1e-9c49-49b6-89a2-56bdec7e3f97"
+ },
+ "signalr1.subscriptionId": {
+ "Name": "signalr1.subscriptionId",
+ "Type": "subscription",
+ "Value": "9caf2a1e-9c49-49b6-89a2-56bdec7e3f97"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.json b/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.json
new file mode 100644
index 00000000..52764ac0
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Properties/serviceDependencies.json
@@ -0,0 +1,12 @@
+{
+ "dependencies": {
+ "signalr1": {
+ "type": "signalr",
+ "connectionId": "Azure__SignalR__ConnectionString"
+ },
+ "storage1": {
+ "type": "storage",
+ "connectionId": "StorageAccountConnectionString"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/ReliableChatRoom.csproj b/samples/ReliableChatRoom/ReliableChatRoom/ReliableChatRoom.csproj
new file mode 100644
index 00000000..c15d541a
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/ReliableChatRoom.csproj
@@ -0,0 +1,19 @@
+
+
+
+ netcoreapp3.1
+ Microsoft.Azure.SignalR.Samples.ReliableChatRoom
+ 62cf7786-de76-4da0-b7e7-93a2a51938b7
+ Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Program
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Startup.cs b/samples/ReliableChatRoom/ReliableChatRoom/Startup.cs
new file mode 100644
index 00000000..74760f0a
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Startup.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Factory;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Handlers;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Hubs;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Storage;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom
+{
+ public class Startup
+ {
+ public Startup(IConfiguration configuration)
+ {
+ Configuration = configuration;
+ }
+
+ public IConfiguration Configuration { get; }
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddSignalR(options =>
+ {
+ options.MaximumReceiveMessageSize = 1024 * 1024 * 1024;
+ })
+ .AddAzureSignalR();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(provider => new NotificationHandler(provider.GetService>(), provider.GetService(), Configuration["ConnectionStrings:AzureNotificationHub:ConnectionString"], Configuration["ConnectionStrings:AzureNotificationHub:HubName"]));
+ services.AddSingleton(provider => new AzureTableMessageStorage(provider.GetService>(), provider.GetService(), Configuration["ConnectionStrings:AzureStorageAccountConnectionString"]));
+ }
+
+ public void Configure(IApplicationBuilder app)
+ {
+ app.UseStaticFiles();
+ app.UseRouting();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapHub("/chat");
+ });
+ }
+ }
+
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Storage/AzureTableMessageStorage.cs b/samples/ReliableChatRoom/ReliableChatRoom/Storage/AzureTableMessageStorage.cs
new file mode 100644
index 00000000..2feba44d
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Storage/AzureTableMessageStorage.cs
@@ -0,0 +1,227 @@
+using Azure.Storage.Blobs;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Azure.Cosmos.Table;
+using Microsoft.Azure.Cosmos.Table.Queryable;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Factory;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Hubs;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Storage
+{
+ public class AzureTableMessageStorage : IMessageStorage
+ {
+ private ILogger _logger;
+
+ private readonly IMessageFactory _messageFactory;
+
+ private readonly CloudStorageAccount _cloudStorageAccount;
+ private readonly CloudTableClient _cloudTableClient;
+ private readonly CloudTable _cloudTable;
+ private readonly string _tableName = "mobilechatroom";
+
+ private readonly BlobServiceClient _blobServiceClient;
+ private readonly BlobContainerClient _blobContainerClient;
+ private readonly string _containerName = "mobilechatroom";
+
+ private readonly string _dateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.ffff";
+ private readonly int _messageCountPerFetch = 10;
+
+ public AzureTableMessageStorage(ILogger logger,
+ IMessageFactory messageFactory,
+ string connectionString)
+ {
+ _logger = logger;
+ _messageFactory = messageFactory;
+
+ _cloudStorageAccount = CloudStorageAccount.Parse(connectionString);
+ _cloudTableClient = _cloudStorageAccount.CreateCloudTableClient(new TableClientConfiguration());
+ _cloudTable = _cloudTableClient.GetTableReference(_tableName);
+
+ _blobServiceClient = new BlobServiceClient(connectionString);
+ _blobContainerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
+ }
+
+ public async Task TryStoreMessageAsync(Message message)
+ {
+ try
+ {
+ MessageEntity messageEntity = CreateMessageEntity(message);
+ List tasks = new List();
+
+ tasks.Add(_cloudTable.ExecuteAsync(TableOperation.InsertOrReplace(messageEntity)));
+
+ if (message.IsImage)
+ {
+ tasks.Add(TryStoreImageAsync(message.MessageId, message.ImagePayload));
+ }
+
+ await Task.WhenAll(tasks);
+ } catch (Exception ex) // Any failure in ExecuteAsync will appear as exception
+ {
+ _logger.LogError(ex.Message);
+ return false;
+ }
+
+ return true;
+ }
+
+ public async Task TryUpdateMessageAsync(Message message)
+ {
+ try
+ {
+ MessageEntity messageEntity = CreateMessageEntity(message);
+ await _cloudTable.ExecuteAsync(TableOperation.InsertOrReplace(messageEntity));
+ }
+ catch (Exception ex) // Any failure in ExecuteAsync will appear as exception
+ {
+ _logger.LogError(ex.Message);
+ return false;
+ }
+
+ return true;
+ }
+
+ private async Task TryStoreImageAsync(string messageId, string imagePayload)
+ {
+ using (var stream = GenerateStreamFromString(imagePayload))
+ {
+ await _blobContainerClient.UploadBlobAsync(messageId, stream);
+ }
+
+ return true;
+ }
+
+ private Stream GenerateStreamFromString(string s)
+ {
+ var stream = new MemoryStream();
+ var writer = new StreamWriter(stream);
+
+ writer.Write(s);
+ writer.Flush();
+ stream.Position = 0;
+
+ return stream;
+ }
+
+ public async Task TryFetchHistoryMessageAsync(string username, DateTime endDateTime, List historyMessages)
+ {
+ string endDateTimeString = endDateTime.ToString(_dateFormatString);
+
+ // Define Linq query
+ TableQuery messageQuery = _cloudTable.CreateQuery();
+ var query = (from message in messageQuery
+ where message.RowKey.CompareTo(endDateTimeString) < 0 &&
+ (message.Sender.CompareTo(username) == 0 ||
+ message.Receiver.CompareTo(username) == 0 ||
+ message.Receiver.CompareTo(Message.BROADCAST_RECEIVER) == 0)
+ select message).AsTableQuery();
+
+ List messageEntities;
+ try
+ {
+ // Execute query
+ TableQuerySegment messageEntitiesQuerySegment;
+ messageEntitiesQuerySegment = await query.ExecuteSegmentedAsync(new TableContinuationToken());
+
+ // Sort by time desc
+ messageEntities = messageEntitiesQuerySegment.ToList();
+ messageEntities.Sort((p, q) => (string.Compare(q.RowKey, p.RowKey)));
+ } catch (Exception ex) // Any failure in ExecuteSegmentedAsync will appear as exception
+ {
+ _logger.LogError(ex.Message);
+
+ // Load failed
+ return false;
+ }
+
+ // Process query result with a limit of
+ foreach (var messageEntity in messageEntities.Take(_messageCountPerFetch))
+ {
+ historyMessages.Add(_messageFactory.FromSingleJsonString(messageEntity.MessageJsonString));
+ }
+
+ return true;
+ }
+
+ public async Task TryFetchImageContentAsync(string messageId)
+ {
+ var blobClient = _blobContainerClient.GetBlobClient(messageId);
+
+ // Download to a stream
+ Stream downloadedStream = (await blobClient.DownloadAsync()).Value.Content;
+
+ // Read the stream into a jsonString
+ StreamReader streamReader = new StreamReader(downloadedStream);
+ string imagePayload = streamReader.ReadToEnd();
+
+ return imagePayload;
+ }
+
+ public async Task TryFetchMessageById(string messageId)
+ {
+ // Define Linq query
+ TableQuery messageQuery = _cloudTable.CreateQuery();
+ var query = (from message in messageQuery
+ where message.PartitionKey.Equals(messageId)
+ select message).AsTableQuery();
+
+ List messageEntities;
+ try
+ {
+ // Execute query
+ TableQuerySegment messageEntitiesQuerySegment;
+ messageEntitiesQuerySegment = await query.ExecuteSegmentedAsync(new TableContinuationToken());
+ messageEntities = messageEntitiesQuerySegment.ToList();
+
+ }
+ catch (Exception ex) // Any failure in ExecuteSegmentedAsync will appear as exception
+ {
+ _logger.LogError(ex.Message);
+
+ // Load failed
+ return null;
+ }
+
+ // Process query result with a limit of
+ foreach (var messageEntity in messageEntities)
+ {
+ return _messageFactory.FromSingleJsonString(messageEntity.MessageJsonString);
+ }
+
+ return null;
+ }
+
+ private MessageEntity CreateMessageEntity(Message message)
+ {
+ return new MessageEntity(message.MessageId, message.SendTime.ToString(_dateFormatString), message.Sender, message.Receiver, _messageFactory.ToSingleJsonString(message));
+ }
+
+ public class MessageEntity : TableEntity
+ {
+ public string Sender { get; set; }
+ public string Receiver { get; set; }
+ public string MessageJsonString { get; set; }
+
+ public MessageEntity()
+ {
+
+ }
+
+ public MessageEntity(string messageId, string dateTimeString, string sender, string receiver, string messageJsonString)
+ {
+ PartitionKey = messageId;
+ RowKey = dateTimeString;
+ Sender = sender;
+ Receiver = receiver;
+ MessageJsonString = messageJsonString;
+ }
+
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/Storage/IMessageStorage.cs b/samples/ReliableChatRoom/ReliableChatRoom/Storage/IMessageStorage.cs
new file mode 100644
index 00000000..e6eda21a
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/Storage/IMessageStorage.cs
@@ -0,0 +1,60 @@
+
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Storage
+{
+ public interface IMessageStorage
+ {
+ ///
+ /// Try to store a into message storage.
+ ///
+ /// Message to store
+ ///
+ /// An Async Task of bool result.
+ /// true - Storage and callback were success
+ /// false - Any of above two was a failure
+ ///
+ Task TryStoreMessageAsync(Message message);
+
+ ///
+ /// Try to update a into message storage.
+ ///
+ /// Message to store
+ ///
+ /// An Async Task of bool result.
+ /// true - Storage and callback were success
+ /// false - Any of above two was a failure
+ ///
+ Task TryUpdateMessageAsync(Message message);
+
+ ///
+ /// Try to fetch a list of history messages according to the username, endDateTime provided by a client
+ ///
+ /// Client's username
+ /// DateTime of the oldest message the client currently has
+ /// Result of fetching
+ ///
+ /// An Async Task of bool result.
+ /// true - Fetch and callback were success
+ /// false - Any of above two was a failure
+ ///
+ Task TryFetchHistoryMessageAsync(string username, DateTime endDateTime, List historyMessages);
+
+ ///
+ /// Try to fetch the content of an image with given messageId
+ ///
+ /// This messageId identifies uniquely the stored image blob
+ ///
+ Task TryFetchImageContentAsync(string messageId);
+
+ ///
+ /// Try to fetch content of a Message with give messageId
+ ///
+ /// This messageId identifies uniquely the stored Message record
+ ///
+ Task TryFetchMessageById(string messageId);
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoom/appsettings.json b/samples/ReliableChatRoom/ReliableChatRoom/appsettings.json
new file mode 100644
index 00000000..9381f1c3
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoom/appsettings.json
@@ -0,0 +1,25 @@
+{
+ "AzureAd": {
+ "Instance": "https://login.microsoftonline.com/",
+ "ClientId": "147b20a7-a4bc-4e1a-b860-02c4ec27a49d",
+ "TenantId": "common"
+ },
+ "Logging": {
+ "IncludeScopes": false,
+ "Debug": {
+ "LogLevel": {
+ "Default": "Information"
+ }
+ },
+ "Console": {
+ "LogLevel": {
+ "Default": "Information"
+ }
+ }
+ },
+ "Azure": {
+ "SignalR": {
+ "Enabled": "true"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/ReliableChatRoom/ReliableChatRoomUnitTest/Entities/MessageUnitTest.cs b/samples/ReliableChatRoom/ReliableChatRoomUnitTest/Entities/MessageUnitTest.cs
new file mode 100644
index 00000000..70069100
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoomUnitTest/Entities/MessageUnitTest.cs
@@ -0,0 +1,19 @@
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace ReliableChatRoomUnitTest.Entities
+{
+ [TestClass]
+ public class MessageUnitTest
+ {
+ [TestMethod]
+ public void Test_Message_GettersAndSetters()
+ {
+ // There is no need to test getters and setters.
+ Assert.IsTrue(true);
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoomUnitTest/Entities/SessionUnitTest.cs b/samples/ReliableChatRoom/ReliableChatRoomUnitTest/Entities/SessionUnitTest.cs
new file mode 100644
index 00000000..1ce7bd1b
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoomUnitTest/Entities/SessionUnitTest.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.Azure.SignalR.Samples.ReliableChatRoom.Entities;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace ReliableChatRoomUnitTest.Entities
+{
+ [TestClass]
+ public class SessionUnitTest
+ {
+ private readonly string _username = "john";
+ private readonly string _connectionId = "ABC";
+ private readonly string _deviceUuid = "DEF";
+
+ [TestMethod]
+ public void Test_Expire()
+ {
+ // Set up
+ Session session = new Session(_username, _connectionId, _deviceUuid);
+
+ // Operation
+ session.Expire();
+
+ // Assertion
+ Assert.AreEqual(session.SessionType, SessionTypeEnum.Expired);
+ }
+ }
+}
diff --git a/samples/ReliableChatRoom/ReliableChatRoomUnitTest/ReliableChatRoomUnitTest.csproj b/samples/ReliableChatRoom/ReliableChatRoomUnitTest/ReliableChatRoomUnitTest.csproj
new file mode 100644
index 00000000..d1396479
--- /dev/null
+++ b/samples/ReliableChatRoom/ReliableChatRoomUnitTest/ReliableChatRoomUnitTest.csproj
@@ -0,0 +1,21 @@
+
+
+
+ netcoreapp3.1
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ReliableChatRoom/assets/1-EnterChatRoom.png b/samples/ReliableChatRoom/assets/1-EnterChatRoom.png
new file mode 100644
index 00000000..c29680fc
Binary files /dev/null and b/samples/ReliableChatRoom/assets/1-EnterChatRoom.png differ
diff --git a/samples/ReliableChatRoom/assets/2-BroadcastMessage.png b/samples/ReliableChatRoom/assets/2-BroadcastMessage.png
new file mode 100644
index 00000000..ef9f4ba7
Binary files /dev/null and b/samples/ReliableChatRoom/assets/2-BroadcastMessage.png differ
diff --git a/samples/ReliableChatRoom/assets/3-PrivateMessage.png b/samples/ReliableChatRoom/assets/3-PrivateMessage.png
new file mode 100644
index 00000000..6c1e992a
Binary files /dev/null and b/samples/ReliableChatRoom/assets/3-PrivateMessage.png differ
diff --git a/samples/ReliableChatRoom/assets/4-PullHistoryMessages.png b/samples/ReliableChatRoom/assets/4-PullHistoryMessages.png
new file mode 100644
index 00000000..c7cb85ba
Binary files /dev/null and b/samples/ReliableChatRoom/assets/4-PullHistoryMessages.png differ
diff --git a/samples/ReliableChatRoom/assets/5-LoadImageContent.png b/samples/ReliableChatRoom/assets/5-LoadImageContent.png
new file mode 100644
index 00000000..f8041771
Binary files /dev/null and b/samples/ReliableChatRoom/assets/5-LoadImageContent.png differ
diff --git a/samples/ReliableChatRoom/assets/6-LeaveChatRoom.png b/samples/ReliableChatRoom/assets/6-LeaveChatRoom.png
new file mode 100644
index 00000000..ffbf73bc
Binary files /dev/null and b/samples/ReliableChatRoom/assets/6-LeaveChatRoom.png differ
diff --git a/samples/ReliableChatRoom/assets/component.png b/samples/ReliableChatRoom/assets/component.png
new file mode 100644
index 00000000..fd98f92b
Binary files /dev/null and b/samples/ReliableChatRoom/assets/component.png differ
diff --git a/samples/ReliableChatRoom/assets/copy-url.png b/samples/ReliableChatRoom/assets/copy-url.png
new file mode 100644
index 00000000..7b22c335
Binary files /dev/null and b/samples/ReliableChatRoom/assets/copy-url.png differ
diff --git a/samples/ReliableChatRoom/assets/create-web-app.png b/samples/ReliableChatRoom/assets/create-web-app.png
new file mode 100644
index 00000000..b5ae1e94
Binary files /dev/null and b/samples/ReliableChatRoom/assets/create-web-app.png differ
diff --git a/samples/ReliableChatRoom/assets/enter-app-service.png b/samples/ReliableChatRoom/assets/enter-app-service.png
new file mode 100644
index 00000000..a18cfa04
Binary files /dev/null and b/samples/ReliableChatRoom/assets/enter-app-service.png differ
diff --git a/samples/ReliableChatRoom/assets/firebase-console-1.png b/samples/ReliableChatRoom/assets/firebase-console-1.png
new file mode 100644
index 00000000..a8313bcb
Binary files /dev/null and b/samples/ReliableChatRoom/assets/firebase-console-1.png differ
diff --git a/samples/ReliableChatRoom/assets/firebase-console-2.png b/samples/ReliableChatRoom/assets/firebase-console-2.png
new file mode 100644
index 00000000..2afb8527
Binary files /dev/null and b/samples/ReliableChatRoom/assets/firebase-console-2.png differ
diff --git a/samples/ReliableChatRoom/assets/log-stream.png b/samples/ReliableChatRoom/assets/log-stream.png
new file mode 100644
index 00000000..6bfb3d6c
Binary files /dev/null and b/samples/ReliableChatRoom/assets/log-stream.png differ
diff --git a/samples/ReliableChatRoom/assets/log.png b/samples/ReliableChatRoom/assets/log.png
new file mode 100644
index 00000000..42d5a7ee
Binary files /dev/null and b/samples/ReliableChatRoom/assets/log.png differ
diff --git a/samples/ReliableChatRoom/assets/notification-hub-1.png b/samples/ReliableChatRoom/assets/notification-hub-1.png
new file mode 100644
index 00000000..ac814bbf
Binary files /dev/null and b/samples/ReliableChatRoom/assets/notification-hub-1.png differ
diff --git a/samples/ReliableChatRoom/assets/notification-hub-2.png b/samples/ReliableChatRoom/assets/notification-hub-2.png
new file mode 100644
index 00000000..6d633fce
Binary files /dev/null and b/samples/ReliableChatRoom/assets/notification-hub-2.png differ
diff --git a/samples/ReliableChatRoom/assets/overview-interface.png b/samples/ReliableChatRoom/assets/overview-interface.png
new file mode 100644
index 00000000..79511e6f
Binary files /dev/null and b/samples/ReliableChatRoom/assets/overview-interface.png differ
diff --git a/samples/ReliableChatRoom/assets/publish.png b/samples/ReliableChatRoom/assets/publish.png
new file mode 100644
index 00000000..36e79268
Binary files /dev/null and b/samples/ReliableChatRoom/assets/publish.png differ
diff --git a/samples/ReliableChatRoom/assets/signalr-1.png b/samples/ReliableChatRoom/assets/signalr-1.png
new file mode 100644
index 00000000..46d29422
Binary files /dev/null and b/samples/ReliableChatRoom/assets/signalr-1.png differ
diff --git a/samples/ReliableChatRoom/assets/signalr-2.png b/samples/ReliableChatRoom/assets/signalr-2.png
new file mode 100644
index 00000000..119bd02b
Binary files /dev/null and b/samples/ReliableChatRoom/assets/signalr-2.png differ
diff --git a/samples/ReliableChatRoom/assets/start-remote-server.png b/samples/ReliableChatRoom/assets/start-remote-server.png
new file mode 100644
index 00000000..c3af1c1c
Binary files /dev/null and b/samples/ReliableChatRoom/assets/start-remote-server.png differ
diff --git a/samples/ReliableChatRoom/assets/storage-1.png b/samples/ReliableChatRoom/assets/storage-1.png
new file mode 100644
index 00000000..c2076922
Binary files /dev/null and b/samples/ReliableChatRoom/assets/storage-1.png differ
diff --git a/samples/ReliableChatRoom/assets/storage-2.png b/samples/ReliableChatRoom/assets/storage-2.png
new file mode 100644
index 00000000..26651f5e
Binary files /dev/null and b/samples/ReliableChatRoom/assets/storage-2.png differ