diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.gitignore b/samples/MobileChatRoom/AndroidChatRoomClient/.gitignore
new file mode 100644
index 00000000..4b84b508
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.gitignore
@@ -0,0 +1,17 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+/app/google-services.json
+/app/src/main/res/values/strings_secrets.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.idea/.gitignore b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/.gitignore
new file mode 100644
index 00000000..5c98b428
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/.gitignore
@@ -0,0 +1,2 @@
+# Default ignored files
+/workspace.xml
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.idea/codeStyles/Project.xml b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/codeStyles/Project.xml
new file mode 100644
index 00000000..663459aa
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/codeStyles/Project.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.idea/compiler.xml b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/compiler.xml
new file mode 100644
index 00000000..61a9130c
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.idea/gradle.xml b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/gradle.xml
new file mode 100644
index 00000000..23a89bbb
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/gradle.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.idea/jarRepositories.xml b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/jarRepositories.xml
new file mode 100644
index 00000000..d36b91b3
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/jarRepositories.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.idea/misc.xml b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/misc.xml
new file mode 100644
index 00000000..d5d35ec4
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.idea/runConfigurations.xml b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/runConfigurations.xml
new file mode 100644
index 00000000..7f68460d
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/runConfigurations.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/.idea/vcs.xml b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/vcs.xml
new file mode 100644
index 00000000..c2365ab1
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/.gitignore b/samples/MobileChatRoom/AndroidChatRoomClient/app/.gitignore
new file mode 100644
index 00000000..2abde4aa
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/.gitignore
@@ -0,0 +1,2 @@
+/build
+/google-services.json
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/build.gradle b/samples/MobileChatRoom/AndroidChatRoomClient/app/build.gradle
new file mode 100644
index 00000000..cc872d2f
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/build.gradle
@@ -0,0 +1,60 @@
+apply plugin: 'com.android.application'
+apply plugin: 'com.google.gms.google-services'
+
+android {
+ compileSdkVersion 30
+ buildToolsVersion '30.0.2'
+
+ defaultConfig {
+ applicationId 'com.signalr.androidchatroom'
+ minSdkVersion 26
+ targetSdkVersion 30
+ versionCode 1
+ versionName '1.0'
+ multiDexEnabled true
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ packagingOptions{
+ exclude("META-INF/jersey-module-version")
+ }
+}
+
+dependencies {
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+ implementation 'androidx.appcompat:appcompat:1.2.0'
+ implementation 'com.google.android.material:material:1.2.1'
+ implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
+ implementation 'androidx.navigation:navigation-fragment:2.3.1'
+ implementation 'androidx.navigation:navigation-ui:2.3.1'
+ implementation 'androidx.navigation:navigation-dynamic-features-fragment:2.3.1'
+ implementation 'com.microsoft.signalr:signalr:5.0.0'
+ implementation 'com.microsoft.azure:notification-hubs-android-sdk:1.1.2@aar'
+ implementation 'com.microsoft.identity.client:msal:2.0.4'
+ implementation 'com.microsoft.graph:microsoft-graph:1.5.0'
+ implementation 'com.google.firebase:firebase-messaging:21.0.0'
+ implementation 'com.android.volley:volley:1.1.1'
+ implementation 'com.google.code.gson:gson:2.8.6'
+ implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
+ testImplementation 'junit:junit:4.13'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.2'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
+ implementation group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.29'
+}
+
+repositories {
+ maven {
+ url "https://dl.bintray.com/microsoftazuremobile/SDK"
+ }
+ jcenter()
+}
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/proguard-rules.pro b/samples/MobileChatRoom/AndroidChatRoomClient/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/AndroidManifest.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..fecbf5e7
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/AndroidManifest.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/ic_launcher-playstore.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 00000000..ca447179
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/ic_launcher-playstore.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/activity/MainActivity.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/activity/MainActivity.java
new file mode 100644
index 00000000..871bc767
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/activity/MainActivity.java
@@ -0,0 +1,124 @@
+package com.signalr.androidchatroom.activity;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.IBinder;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+
+import com.signalr.androidchatroom.R;
+import com.signalr.androidchatroom.service.FirebaseService;
+import com.signalr.androidchatroom.service.NotificationService;
+import com.signalr.androidchatroom.view.ChatFragment;
+import com.signalr.androidchatroom.view.LoginFragment;
+
+/**
+ * Main entrance of the application.
+ */
+public class MainActivity extends AppCompatActivity {
+ private static final String TAG = MainActivity.class.getSimpleName();
+
+ /*
+ * Used for notification display
+ * Display notification when MainActivity is not visible
+ */
+ private static MainActivity sMainActivity;
+ private boolean mIsVisible = false;
+
+ /* View components */
+ private LoginFragment mLoginFragment;
+ private ChatFragment mChatFragment;
+
+ /* Instance of NotificationService */
+ private NotificationService notificationService;
+ /* Service connection that get the ref of NotificationService object */
+ private final ServiceConnection notificationServiceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ NotificationService.NotificationServiceBinder notificationServiceBinder =
+ (NotificationService.NotificationServiceBinder) service;
+ notificationService = notificationServiceBinder.getService();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ notificationService = null;
+ }
+ };
+
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ sMainActivity = this;
+ setContentView(R.layout.activity_main);
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+
+ bindNotificationService();
+ FirebaseService.createNotificationChannel(getApplicationContext());
+ }
+
+ public void bindNotificationService() {
+ Intent intent = new Intent(this, NotificationService.class);
+ bindService(intent, notificationServiceConnection, Context.BIND_AUTO_CREATE);
+ }
+
+ @Override
+ public void onBackPressed() {
+ /* If back pressed, manually logout user */
+ if (mChatFragment != null) {
+ mChatFragment.onBackPressed();
+ mChatFragment = null;
+ }
+ super.onBackPressed();
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ mIsVisible = true;
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ mIsVisible = false;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mIsVisible = true;
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ mIsVisible = false;
+ }
+
+ public void setLoginFragment(LoginFragment loginFragment) {
+ this.mLoginFragment = loginFragment;
+ }
+
+ public void setChatFragment(ChatFragment chatFragment) {
+ this.mChatFragment = chatFragment;
+ }
+
+ public static MainActivity getActiveInstance() {
+ return sMainActivity;
+ }
+
+ public NotificationService getNotificationService() {
+ return notificationService;
+ }
+
+ public boolean isVisible() {
+ return mIsVisible;
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/ChatContract.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/ChatContract.java
new file mode 100644
index 00000000..99e2ee11
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/ChatContract.java
@@ -0,0 +1,217 @@
+package com.signalr.androidchatroom.contract;
+
+import android.graphics.Bitmap;
+
+import com.signalr.androidchatroom.model.entity.Message;
+import com.signalr.androidchatroom.view.ScrollDirection;
+
+import java.util.List;
+
+/**
+ * Contract for chatting functions
+ * Defined in MVP (Model-View-Presenter) Pattern
+ */
+public interface ChatContract {
+ interface Presenter {
+ /* Called by view */
+
+ /**
+ * Sends a text message with given sender, receiver, and payload to chat model.
+ *
+ * @param sender A string representing sender name.
+ * @param receiver A string representing receiver name.
+ * @param payload A string representing message content.
+ */
+ void sendTextMessage(String sender, String receiver, String payload);
+
+ /**
+ * Sends a text message with given sender, receiver, and image bitmap to chat model
+ *
+ * @param sender A string representing sender name.
+ * @param receiver A string representing receiver name.
+ * @param image A bitmap representing the image to send.
+ */
+ void sendImageMessage(String sender, String receiver, Bitmap image);
+
+ /**
+ * Sends a read response to chat model indicating that the client has read
+ * the message to chat model.
+ *
+ * @param messageId A string representing message id.
+ */
+ void sendMessageRead(String messageId);
+
+ /**
+ * Re-sends a message after a previous send operation's timing out to chat model.
+ *
+ * @param messageId A string representing message id.
+ */
+ void resendMessage(String messageId);
+
+ /**
+ * Requests to pull history messages given a callback scroll direction.
+ *
+ * @param callbackDirection KEEP_POSITION for keep current scroll position
+ * FINGER_UP for scroll to end after setting messages
+ * FINGER_DOWN for scroll to start after setting messages
+ */
+ void pullHistoryMessages(ScrollDirection callbackDirection);
+
+ /**
+ * Requests to pull image content from chat model.
+ *
+ * @param messageId A string representing message id.
+ */
+ void pullImageContent(String messageId);
+
+ /**
+ * Saves the current collection of messages to the SharedPreference API
+ */
+ void saveHistoryMessages();
+
+ /**
+ * Manually requests to log out by client.
+ */
+ void requestLogout();
+
+ /* Called by chat model */
+
+ /**
+ * Receives an Ack message and sets the corresponding message's status
+ * to 'SENT'.
+ *
+ * q@param messageId A string representing message id.
+ * @param receivedTimeInLong A long int representing the received time in milliseconds.
+ */
+ void receiveMessageAck(String messageId, long receivedTimeInLong);
+
+ /**
+ * Receives an Read message and sets the corresponding message's status
+ * to 'READ'.
+ *
+ * @implNote Should ignore the call when the messageId of a non-private message,
+ * since only private messages have a state of 'READ'.
+ *
+ * @param messageId A string representing message id.
+ */
+ void receiveMessageRead(String messageId);
+
+ /**
+ * Receives an image content and sets the message's image field
+ *
+ * @param messageId A string representing message id.
+ * @param bmp A bitmap object containing the content of image.
+ */
+ void receiveImageContent(String messageId, Bitmap bmp);
+
+ /**
+ * Adds a message to the message list of presenter.
+ *
+ * @param message A message object to add.
+ */
+ void addMessage(Message message);
+
+ /**
+ * Adds a message and sends back the client ack message.
+ *
+ * @param message A message object to add.
+ * @param ackId A unique string representing an ack message, generated and sent from server.
+ */
+ void addMessage(Message message, String ackId);
+
+ /**
+ * Adds a list of messages to the message list of presenter.
+ *
+ * @param messages A list of messages to add.
+ */
+ void addAllMessages(List messages);
+
+ /**
+ * Confirms a log out request and does corresponding log out works in presenter.
+ *
+ * @param isForced If true, display a force log out dialogue. Otherwise, simply log out.
+ */
+ void confirmLogout(boolean isForced);
+
+ }
+
+ interface View {
+ /**
+ * Activates all event listeners in the view. Usually called after presenter is set up.
+ */
+ void activateListeners();
+
+ /**
+ * Deactivate all event listeners in the view.
+ */
+ void deactivateListeners();
+
+ /**
+ * Set and refresh messages to display in view.
+ *
+ * @param messages A list of messages to display.
+ * @param direction KEEP_POSITION for keep current scroll position
+ * FINGER_UP for scroll to end after setting messages
+ * FINGER_DOWN for scroll to start after setting messages
+ */
+ void setMessages(List messages, ScrollDirection direction);
+
+ /**
+ * Set up log out works in view.
+ *
+ * @param isForced If true, display a force log out dialogue. Otherwise, simply log out.
+ */
+ void setLogout(boolean isForced);
+ }
+
+ interface Model {
+ /**
+ * Send a broadcast message to SignalR layer.
+ *
+ * @param broadcastMessage A broadcast message.
+ */
+ void sendBroadcastMessage(Message broadcastMessage);
+
+ /**
+ * Send a private message to SignalR layer.
+ *
+ * @param privateMessage A private message.
+ */
+ void sendPrivateMessage(Message privateMessage);
+
+ /**
+ * Send a Read response to SignalR layer.
+ *
+ * @param messageId A string representing the message id that the response wants to
+ * confirm read.
+ */
+ void sendMessageRead(String messageId);
+
+ /**
+ * Send a Ack response to SignalR layer.
+ *
+ * @param ackId A string representing the ack id that the response wants to confirm ack.
+ */
+ void sendAck(String ackId);
+
+ /**
+ * Send a request to pull history messages before a given time to the SignalR layer.
+ *
+ * @param untilTimeInLong A long representing the given time.
+ */
+ void pullHistoryMessages(long untilTimeInLong);
+
+ /**
+ * Send a request to pull (downlaod) the content of a given image message
+ * to the SignalR layer.
+ *
+ * @param messageId A string representing the message id of the image message.
+ */
+ void pullImageContent(String messageId);
+
+ /**
+ * Send a log out request to the SignalR layer.
+ */
+ void logout();
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/LoginContract.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/LoginContract.java
new file mode 100644
index 00000000..fc88cf25
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/LoginContract.java
@@ -0,0 +1,74 @@
+package com.signalr.androidchatroom.contract;
+
+import android.app.Activity;
+
+import com.signalr.androidchatroom.util.SimpleCallback;
+
+/**
+ * Contract for login and start connection functions
+ * Defined in MVP (Model-View-Presenter) Pattern
+ */
+public interface LoginContract {
+
+ interface Presenter {
+ /* Called by view */
+
+ /**
+ * Try to sign into the chat server.
+ *
+ * @param refreshUiCallback Defines the corresponding behavior when success/error happens
+ */
+ void signIn(SimpleCallback refreshUiCallback);
+ }
+
+ interface View {
+ /* Called by model */
+
+ /**
+ * Sets a login status and navigate to chat fragment when successful.
+ *
+ * @param username Username confirmed by chat server.
+ */
+ void setLogin(String username);
+ }
+
+ interface Model {
+ /* Called by presenter*/
+
+ /**
+ * Create a ISingleAccountPublicClientApplication for AAD sign-in.
+ *
+ * @param callback Action to take when success/error
+ */
+ void createClientApplication(SimpleCallback callback);
+
+ /**
+ * Sign into AAD.
+ *
+ * @param usernameCallback If success pass back the username, otherwise handle error
+ */
+ void signIn(SimpleCallback usernameCallback);
+
+ /**
+ * Acquire idToken again in case the sign-in response returned a broken token.
+ *
+ * @param idTokenCallback If success pass back the idToken, otherwise handle error
+ */
+ void acquireIdToken(SimpleCallback idTokenCallback);
+
+ /**
+ * Call SignalR layer methods to enter the chat room with given credentials.
+ *
+ * @param idToken AAD id token
+ * @param username Username
+ * @param callback Action to take when success/error
+ */
+ void enterChatRoom(String idToken, String username, SimpleCallback callback);
+
+ /**
+ * Fetch device uuid after connection to notification hub is established.
+ */
+ void refreshDeviceUuid();
+
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/ServerContract.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/ServerContract.java
new file mode 100644
index 00000000..690d5619
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/contract/ServerContract.java
@@ -0,0 +1,85 @@
+package com.signalr.androidchatroom.contract;
+
+/**
+ * Defines server callbacks
+ */
+public interface ServerContract {
+ /**
+ * Receives a system message from SignalR server.
+ *
+ * @param messageId A server-generated unique string of message id.
+ * @param payload Body content of the system message (can only be text).
+ * @param sendTime A long int representing the time when the system was sent.
+ */
+ void receiveSystemMessage(String messageId, String payload, long sendTime);
+
+ /**
+ * Received a broadcast message from SignalR server.
+ *
+ * @param messageId A server-generated unique string of message id.
+ * @param sender A string of sender.
+ * @param receiver A string of receiver (can only be "BCAST").
+ * @param payload Body content of the broadcast message (can be either text
+ * or binary image contents encoded in base64).
+ * @param isImage A boolean indicating whether the broadcast is an image message.
+ * @param sendTime A long int representing the time when the system was sent.
+ * @param ackId A string of ack id that client will later send back to server as Ack response.
+ */
+ void receiveBroadcastMessage(String messageId, String sender, String receiver, String payload, boolean isImage, long sendTime, String ackId);
+
+ /**
+ * Received a private message from SignalR server.
+ *
+ * @param messageId A server-generated unique string of message id.
+ * @param sender A string of sender.
+ * @param receiver A string of receiver.
+ * @param payload Body content of the broadcast message (can be either text
+ * or binary image contents encoded in base64).
+ * @param isImage A boolean indicating whether the broadcast is an image message.
+ * @param sendTime A long int representing the time when the system was sent.
+ * @param ackId A string of ack id that client will later send back to server as Ack response.
+ */
+ void receivePrivateMessage(String messageId, String sender, String receiver, String payload, boolean isImage, long sendTime, String ackId);
+
+ /**
+ * Receives history messages from SignalR server.
+ * Usually called by server right after a client pullHistoryMessage request.
+ *
+ * @param serializedString A json string of list of history messages.
+ */
+ void receiveHistoryMessages(String serializedString);
+
+ /**
+ * Receives image content from SignalR server.
+ *
+ * @param messageId A string representing a message id.
+ * @param payload Binary image contents encoded in base64.
+ */
+ void receiveImageContent(String messageId, String payload);
+
+ /**
+ * Receives a client read response from SignalR server.
+ * The client read response is sent from another client to server.
+ *
+ * @param messageId A string representing a message id.
+ * @param username A string representing the username of sender of the read response.
+ */
+ void clientRead(String messageId, String username);
+
+ /**
+ * Receives a server ack from SignalR server.
+ * The server ack is a server response of successfully receiving a client message.
+ *
+ * @param messageId A string representing a message id.
+ * @param receivedTimeInLong A long int representing the received time in milliseconds.
+ */
+ void serverAck(String messageId, long receivedTimeInLong);
+
+ /**
+ * Expires a client session from SignalR server.
+ *
+ * @param isForced A boolean indicating whether the expire is forced.
+ */
+ void expireSession(boolean isForced);
+
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/BaseModel.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/BaseModel.java
new file mode 100644
index 00000000..fcbf555f
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/BaseModel.java
@@ -0,0 +1,13 @@
+package com.signalr.androidchatroom.model;
+
+/**
+ * Base model component in Model-View-Presenter design.
+ */
+public abstract class BaseModel {
+ private static final String TAG = "BaseModel";
+
+ public void detach() {
+
+ }
+
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/ChatModel.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/ChatModel.java
new file mode 100644
index 00000000..607e1fc8
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/ChatModel.java
@@ -0,0 +1,209 @@
+package com.signalr.androidchatroom.model;
+
+import android.graphics.Bitmap;
+import android.util.Log;
+
+import com.signalr.androidchatroom.activity.MainActivity;
+import com.signalr.androidchatroom.contract.ChatContract;
+import com.signalr.androidchatroom.contract.ServerContract;
+import com.signalr.androidchatroom.model.entity.Message;
+import com.signalr.androidchatroom.model.entity.MessageFactory;
+import com.signalr.androidchatroom.presenter.ChatPresenter;
+import com.signalr.androidchatroom.service.AuthenticationService;
+import com.signalr.androidchatroom.service.SignalRService;
+
+import org.apache.log4j.chainsaw.Main;
+
+import java.util.List;
+
+import io.reactivex.SingleObserver;
+import io.reactivex.annotations.NonNull;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+/**
+ * Model component responsible for chatting.
+ */
+public class ChatModel extends BaseModel implements ChatContract.Model, ServerContract {
+ private static final String TAG = "ChatModel";
+
+ private ChatPresenter mChatPresenter;
+ private MainActivity mMainActivity;
+
+ public ChatModel(ChatPresenter chatPresenter, MainActivity mainActivity) {
+ mChatPresenter = chatPresenter;
+ mMainActivity = mainActivity;
+ registerServerCallbacks();
+ }
+
+ private void registerServerCallbacks() {
+ SignalRService.registerServerCallback("receiveSystemMessage", this::receiveSystemMessage,
+ String.class, String.class, Long.class);
+ SignalRService.registerServerCallback("receiveBroadcastMessage", this::receiveBroadcastMessage,
+ String.class, String.class, String.class, String.class, Boolean.class, Long.class, String.class);
+ SignalRService.registerServerCallback("receivePrivateMessage", this::receivePrivateMessage,
+ String.class, String.class, String.class, String.class, Boolean.class, Long.class, String.class);
+
+ SignalRService.registerServerCallback("receiveHistoryMessages", this::receiveHistoryMessages, String.class);
+ SignalRService.registerServerCallback("receiveImageContent", this::receiveImageContent, String.class, String.class);
+
+ SignalRService.registerServerCallback("serverAck", this::serverAck, String.class, Long.class);
+ SignalRService.registerServerCallback("clientRead", this::clientRead, String.class, String.class);
+ SignalRService.registerServerCallback("expireSession", this::expireSession, Boolean.class);
+ }
+
+ @Override
+ public void sendPrivateMessage(Message privateMessage) {
+ SignalRService.sendPrivateMessage(privateMessage.getMessageId(), privateMessage.getSender(), privateMessage.getReceiver(), privateMessage.getPayload(), privateMessage.isImage());
+ }
+
+ @Override
+ public void sendBroadcastMessage(Message broadcastMessage) {
+ SignalRService.sendBroadcastMessage(broadcastMessage.getMessageId(), broadcastMessage.getSender(), broadcastMessage.getPayload(), broadcastMessage.isImage());
+ }
+
+ @Override
+ public void sendMessageRead(String messageId) {
+ SignalRService.sendMessageRead(messageId);
+ }
+
+ @Override
+ public void sendAck(String ackId) {
+ SignalRService.sendAck(ackId);
+ }
+
+ @Override
+ public void pullHistoryMessages(long untilTimeInLong) {
+ SignalRService.pullHistoryMessages(untilTimeInLong);
+ }
+
+ @Override
+ public void pullImageContent(String messageId) {
+ SignalRService.pullImageContent(messageId);
+ }
+
+ @Override
+ public void logout() {
+ SignalRService
+ .logout()
+ .subscribeOn(Schedulers.io()) /* Use io-oriented thread scheduler */
+ .observeOn(Schedulers.io())
+ .subscribe(new SingleObserver() {
+ @Override
+ public void onSubscribe(@NonNull Disposable d) {
+
+ }
+
+ @Override
+ public void onSuccess(@NonNull String s) {
+ /* Once server confirms the log out request
+ * stop the reconnect timer thread
+ * and then stop the hub connection.
+ */
+ SignalRService.stopReconnectTimer();
+ SignalRService.stopHubConnection();
+ AuthenticationService.signOut();
+ }
+
+ @Override
+ public void onError(@NonNull Throwable e) {
+ /* Once server fails to confirm log out request,
+ * log out anyway (Server will expire the client's
+ * session after a period of inactive time).
+ * Stop the reconnect timer thread and then stop
+ * the hub connection.
+ */
+ SignalRService.stopReconnectTimer();
+ SignalRService.stopHubConnection();
+ AuthenticationService.signOut();
+ }
+ });
+ }
+
+ @Override
+ public void receiveSystemMessage(String messageId, String payload, long sendTime) {
+ Log.d(TAG, "receiveSystemMessage: " + payload);
+
+ /* Create message */
+ Message systemMessage = MessageFactory.createReceivedSystemMessage(messageId, payload, sendTime);
+
+ /* Try to add message to chat presenter */
+ mChatPresenter.addMessage(systemMessage);
+ }
+
+ @Override
+ public void receiveBroadcastMessage(String messageId, String sender, String receiver, String payload, boolean isImage, long sendTime, String ackId) {
+ Log.d(TAG, "receiveBroadcastMessage from: " + sender);
+
+ /* Create message */
+ Message chatMessage;
+ if (isImage) {
+ chatMessage = MessageFactory.createReceivedImageBroadcastMessage(messageId, sender, payload, sendTime);
+ } else {
+ chatMessage = MessageFactory.createReceivedTextBroadcastMessage(messageId, sender, payload, sendTime);
+ }
+
+ /* Try to add message to chat presenter */
+ mChatPresenter.addMessage(chatMessage, ackId);
+ }
+
+ @Override
+ public void receivePrivateMessage(String messageId, String sender, String receiver, String payload, boolean isImage, long sendTime, String ackId) {
+ Log.d(TAG, "receivePrivateMessage from: " + sender);
+
+ /* Create message */
+ Message chatMessage;
+ if (isImage) {
+ chatMessage = MessageFactory.createReceivedImagePrivateMessage(messageId, sender, receiver, payload, sendTime);
+ } else {
+ chatMessage = MessageFactory.createReceivedTextPrivateMessage(messageId, sender, receiver, payload, sendTime);
+ }
+
+ /* Try to add message to chat presenter */
+ mChatPresenter.addMessage(chatMessage, ackId);
+ }
+
+ @Override
+ public void receiveImageContent(String messageId, String payload) {
+ Log.d(TAG, "receiveImageContent");
+
+ /* Decode base64 string to bitmap object */
+ Bitmap bmp = MessageFactory.decodeToBitmap(payload);
+
+ /* Send bitmap to chat presenter */
+ mChatPresenter.receiveImageContent(messageId, bmp);
+ }
+
+ @Override
+ public void receiveHistoryMessages(String serializedString) {
+ Log.d(TAG, "receiveHistoryMessages");
+
+ /* Parse JSON array string to list of messages */
+ List historyMessages = MessageFactory.parseHistoryMessages(serializedString, SignalRService.getUsername());
+
+ /* Add history messages to chat presenter */
+ mChatPresenter.addAllMessages(historyMessages);
+ }
+
+ @Override
+ public void serverAck(String messageId, long receivedTimeInLong) {
+ mChatPresenter.receiveMessageAck(messageId, receivedTimeInLong);
+ }
+
+ @Override
+ public void clientRead(String messageId, String username) {
+ mChatPresenter.receiveMessageRead(messageId);
+ }
+
+ @Override
+ public void detach() {
+ mChatPresenter = null;
+ mMainActivity = null;
+ }
+
+ @Override
+ public void expireSession(boolean isForced) {
+ mChatPresenter.confirmLogout(isForced);
+ mChatPresenter.detach();
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/LoginModel.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/LoginModel.java
new file mode 100644
index 00000000..505d280c
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/LoginModel.java
@@ -0,0 +1,143 @@
+package com.signalr.androidchatroom.model;
+
+import android.util.Log;
+
+import com.signalr.androidchatroom.activity.MainActivity;
+import com.signalr.androidchatroom.contract.LoginContract;
+import com.signalr.androidchatroom.presenter.LoginPresenter;
+import com.signalr.androidchatroom.service.AuthenticationService;
+import com.signalr.androidchatroom.service.SignalRService;
+import com.signalr.androidchatroom.util.SimpleCallback;
+
+
+import io.reactivex.SingleObserver;
+import io.reactivex.annotations.NonNull;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+/**
+ * Model component responsible for authorization and logging in.
+ */
+public class LoginModel extends BaseModel implements LoginContract.Model {
+ private static final String TAG = "LoginModel";
+
+ private LoginPresenter mLoginPresenter;
+ private MainActivity mMainActivity;
+ private String mDeviceUuid;
+
+ public LoginModel(LoginPresenter loginPresenter, MainActivity mainActivity) {
+ mLoginPresenter = loginPresenter;
+ mMainActivity = mainActivity;
+ }
+
+ @Override
+ public void createClientApplication(SimpleCallback createApplicationCallback) {
+ AuthenticationService
+ .createClientApplication(
+ mMainActivity.getApplicationContext(),
+ new SimpleCallback() {
+ @Override
+ public void onSuccess(Void aVoid) {
+ createApplicationCallback.onSuccess(null);
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+ createApplicationCallback.onError(errorMessage);
+ }
+ });
+ }
+
+ @Override
+ public void signIn(SimpleCallback signInCallback) {
+ AuthenticationService
+ .loadActiveAccountOrSignIn(mMainActivity, new SimpleCallback() {
+ @Override
+ public void onSuccess(String username) {
+ Log.d(TAG, "signIn callback get Username: "+username);
+ signInCallback.onSuccess(username);
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+ signInCallback.onError(errorMessage);
+ }
+ });
+ }
+
+ @Override
+ public void acquireIdToken(SimpleCallback idTokenCallback) {
+ AuthenticationService
+ .acquireIdToken(new SimpleCallback() {
+ @Override
+ public void onSuccess(String idToken) {
+ idTokenCallback.onSuccess(idToken);
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+ idTokenCallback.onError(errorMessage);
+ }
+ });
+ }
+
+ @Override
+ public void enterChatRoom(String idToken, String username, SimpleCallback callback) {
+ SignalRService.startHubConnection(new SimpleCallback() {
+ @Override
+ public void onSuccess(Void aVoid) {
+ SignalRService.login(mDeviceUuid, username)
+ .subscribeOn(Schedulers.io())
+ .observeOn(Schedulers.io())
+ .subscribe(new SingleObserver() {
+ @Override
+ public void onSubscribe(@NonNull Disposable d) {
+
+ }
+
+ @Override
+ public void onSuccess(@NonNull String s) {
+ /* Once server confirms the log in request,
+ * call onSuccess callback and then start
+ * the reconnect timer.
+ */
+ callback.onSuccess(null);
+
+ /* Start timer in service */
+ SignalRService.startReconnectTimer();
+ }
+
+ @Override
+ public void onError(@NonNull Throwable e) {
+ /* If server fails to confirm the log in
+ * request, call onError callback.
+ */
+ callback.onError(e.getMessage());
+ }
+ });
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+ /* If fails to start hub connection (negotiation)
+ * request, call onError callback.
+ */
+ callback.onError(errorMessage);
+ }
+ }, idToken);
+
+
+ }
+
+ @Override
+ public void refreshDeviceUuid() {
+ mDeviceUuid = mMainActivity.getNotificationService().getDeviceUuid();
+ }
+
+ @Override
+ public void detach() {
+ mLoginPresenter = null;
+ mMainActivity = null;
+ mDeviceUuid = null;
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/Message.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/Message.java
new file mode 100644
index 00000000..fec953c4
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/Message.java
@@ -0,0 +1,121 @@
+package com.signalr.androidchatroom.model.entity;
+
+import android.graphics.Bitmap;
+
+import com.signalr.androidchatroom.util.MessageTypeUtils;
+
+import java.util.UUID;
+
+/**
+ * Entity class for a chat message
+ */
+public class Message {
+ public final static String BROADCAST_RECEIVER = "BCAST";
+ public final static String SYSTEM_SENDER = "SYS";
+
+ private String messageId;
+ private int messageType;
+ private String sender;
+ private String receiver;
+ private String payload;
+ private long time;
+
+ private Bitmap bmp;
+
+ public Message(String messageId, int messageType) {
+ this.messageId = messageId;
+ this.messageType = messageType;
+ }
+
+ public Message(int messageType) {
+ this.messageId = UUID.randomUUID().toString();
+ this.messageType = messageType;
+ }
+
+ public String getMessageId() {
+ return messageId;
+ }
+
+ public void setMessageId(String messageId) {
+ this.messageId = messageId;
+ }
+
+ public int getMessageType() {
+ return messageType;
+ }
+
+ public void setMessageType(int messageType) {
+ this.messageType = messageType;
+ }
+
+ public String getSender() {
+ return sender;
+ }
+
+ public void setSender(String sender) {
+ this.sender = sender;
+ }
+
+ public String getReceiver() {
+ return receiver;
+ }
+
+ public void setReceiver(String receiver) {
+ this.receiver = receiver;
+ }
+
+ public boolean isImage() {
+ return (messageType & MessageTypeConstant.MESSAGE_CONTENT_MASK) == MessageTypeConstant.IMAGE;
+ }
+
+ public String getPayload() {
+ return payload;
+ }
+
+ public void setPayload(String payload) {
+ this.payload = payload;
+ }
+
+ public long getTime() {
+ return time;
+ }
+
+ public void setTime(long time) {
+ this.time = time;
+ }
+
+ public Bitmap getBmp() {
+ return bmp;
+ }
+
+ public void setBmp(Bitmap bmp) {
+ this.bmp = bmp;
+ }
+
+ public boolean isRead() {
+ return (messageType & MessageTypeConstant.MESSAGE_STATUS_MASK) == MessageTypeConstant.READ;
+ }
+
+
+ public void ack(long receivedTimeInLong) {
+ if ((messageType & MessageTypeConstant.MESSAGE_STATUS_MASK) == MessageTypeConstant.SENDING) {
+ /* Set SENDING -> SENT */
+ messageType = MessageTypeUtils.setStatus(messageType, MessageTypeConstant.SENT);
+ setTime(receivedTimeInLong);
+ }
+ }
+
+ public void read() {
+ if ((messageType & MessageTypeConstant.MESSAGE_STATUS_MASK) == MessageTypeConstant.SENT) {
+ /* Set SENT -> READ */
+ messageType = MessageTypeUtils.setStatus(messageType, MessageTypeConstant.READ);
+ }
+ }
+
+ public void timeout() {
+ if ((messageType & MessageTypeConstant.MESSAGE_STATUS_MASK) == MessageTypeConstant.SENDING) {
+ /* Set SENDING -> SENT */
+ messageType = MessageTypeUtils.setStatus(messageType, MessageTypeConstant.TIMEOUT);
+ }
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/MessageFactory.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/MessageFactory.java
new file mode 100644
index 00000000..841a8e82
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/MessageFactory.java
@@ -0,0 +1,343 @@
+package com.signalr.androidchatroom.model.entity;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.util.Log;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.signalr.androidchatroom.util.MessageTypeUtils;
+import com.signalr.androidchatroom.util.SimpleCallback;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Defines factory methods for manipulating Message class
+ */
+public class MessageFactory {
+ /* SimpleDateFormat utility object for date format decoding and encoding */
+ private final static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS'Z'", Locale.US);
+
+ /* Offset in millisecond between GMT+0 and GMT+8 (8 hours) */
+ private final static long utcOffset = 1000 * 3600 * 8;
+
+ /* Gson utility object for JSON decoding and encoding */
+ private final static Gson gson = new Gson();
+
+ /**
+ * Creates a received text broadcast message.
+ *
+ * @param messageId A string of message id.
+ * @param sender A string of sender client username.
+ * @param payload A string of text message body.
+ * @param time A long int of send time of the message in milliseconds.
+ * @return A corresponding Message object created.
+ */
+ public static Message createReceivedTextBroadcastMessage(String messageId, String sender, String payload, long time) {
+ Message message = new Message(messageId, MessageTypeUtils.calculateMessageType(MessageTypeConstant.BROADCAST, MessageTypeConstant.TEXT, MessageTypeConstant.RECEIVED));
+ message.setSender(sender);
+ message.setReceiver(Message.BROADCAST_RECEIVER);
+ message.setPayload(payload);
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Creates a received image broadcast message.
+ *
+ * @param messageId A string of message id.
+ * @param sender A string of sender client username.
+ * @param payload A string of base64 encoded image content.
+ * @param time A long int of send time of the message in milliseconds.
+ * @return A corresponding Message object created.
+ */
+ public static Message createReceivedImageBroadcastMessage(String messageId, String sender, String payload, long time) {
+ Message message = new Message(messageId, MessageTypeUtils.calculateMessageType(MessageTypeConstant.BROADCAST, MessageTypeConstant.IMAGE, MessageTypeConstant.RECEIVED));
+ message.setSender(sender);
+ message.setReceiver(Message.BROADCAST_RECEIVER);
+ message.setPayload(payload);
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Creates a sending text broadcast message.
+ *
+ * @param sender A string of sender client username.
+ * @param payload A string of text message body.
+ * @param time A long int of send time of the message in milliseconds.
+ * @return A corresponding Message object created.
+ */
+ public static Message createSendingTextBroadcastMessage(String sender, String payload, long time) {
+ Message message = new Message(MessageTypeUtils.calculateMessageType(MessageTypeConstant.BROADCAST, MessageTypeConstant.TEXT, MessageTypeConstant.SENDING));
+ message.setSender(sender);
+ message.setReceiver(Message.BROADCAST_RECEIVER);
+ message.setPayload(payload);
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Creates a sending image broadcast message.
+ *
+ * @param sender A string of sender client username.
+ * @param bmp A bitmap representation of image.
+ * @param time A long int of send time of the message in milliseconds.
+ * @param callback A callback function called when send completed
+ * @return A corresponding Message object created.
+ */
+ public static Message createSendingImageBroadcastMessage(String sender, Bitmap bmp, long time, SimpleCallback callback) {
+ Message message = new Message(MessageTypeUtils.calculateMessageType(MessageTypeConstant.BROADCAST, MessageTypeConstant.IMAGE, MessageTypeConstant.SENDING));
+ message.setSender(sender);
+ message.setReceiver(Message.BROADCAST_RECEIVER);
+ message.setBmp(bmp);
+ new Thread(() -> {
+ String payload = encodeToBase64(bmp);
+ message.setPayload(payload);
+ callback.onSuccess(message);
+ }).start();
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Creates a received text private message.
+ *
+ * @param messageId A string of message id.
+ * @param sender A string of sender client username.
+ * @param receiver A string of receiver client username.
+ * @param payload A string of text message body.
+ * @param time A long int of send time of the message in milliseconds.
+ * @return A corresponding Message object created.
+ */
+ public static Message createReceivedTextPrivateMessage(String messageId, String sender, String receiver, String payload, long time) {
+ Message message = new Message(messageId, MessageTypeUtils.calculateMessageType(MessageTypeConstant.PRIVATE, MessageTypeConstant.TEXT, MessageTypeConstant.RECEIVED));
+ message.setSender(sender);
+ message.setReceiver(receiver);
+ message.setPayload(payload);
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Creates a received image private message.
+ *
+ * @param messageId A string of message id.
+ * @param sender A string of sender client username.
+ * @param receiver A string of receiver client username.
+ * @param payload A string of base64 encoded image content.
+ * @param time A long int of send time of the message in milliseconds.
+ * @return A corresponding Message object created.
+ */
+ public static Message createReceivedImagePrivateMessage(String messageId, String sender, String receiver, String payload, long time) {
+ Message message = new Message(messageId, MessageTypeUtils.calculateMessageType(MessageTypeConstant.PRIVATE, MessageTypeConstant.IMAGE, MessageTypeConstant.RECEIVED));
+ message.setSender(sender);
+ message.setReceiver(receiver);
+ message.setPayload(payload);
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Creates a sending text private message.
+ *
+ * @param sender A string of sender client username.
+ * @param receiver A string of receiver client username.
+ * @param payload A string of text message body.
+ * @param time A long int of send time of the message in milliseconds.
+ * @return A corresponding Message object created.
+ */
+ public static Message createSendingTextPrivateMessage(String sender, String receiver, String payload, long time) {
+ Message message = new Message(MessageTypeUtils.calculateMessageType(MessageTypeConstant.PRIVATE, MessageTypeConstant.TEXT, MessageTypeConstant.SENDING));
+ message.setSender(sender);
+ message.setReceiver(receiver);
+ message.setPayload(payload);
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Creates a sending image private message.
+ *
+ * @param sender A string of sender client username.
+ * @param receiver A string of receiver client username.
+ * @param bmp A bitmap representation of image.
+ * @param time A long int of send time of the message in milliseconds.
+ * @return A corresponding Message object created.
+ */
+ public static Message createSendingImagePrivateMessage(String sender, String receiver, Bitmap bmp, long time, SimpleCallback callback) {
+ Message message = new Message(MessageTypeUtils.calculateMessageType(MessageTypeConstant.PRIVATE, MessageTypeConstant.IMAGE, MessageTypeConstant.SENDING));
+ message.setSender(sender);
+ message.setReceiver(receiver);
+ message.setBmp(bmp);
+ new Thread(() -> {
+ String payload = encodeToBase64(bmp);
+ message.setPayload(payload);
+ callback.onSuccess(message);
+ }).start();
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Creates a received system message.
+ *
+ * @param messageId A string of message id.
+ * @param payload A string of text message body.
+ * @param time A long int of send time of the message in milliseconds.
+ * @return A corresponding Message object created.
+ */
+ public static Message createReceivedSystemMessage(String messageId, String payload, long time) {
+ Message message = new Message(messageId, MessageTypeUtils.calculateMessageType(MessageTypeConstant.SYSTEM, MessageTypeConstant.TEXT, MessageTypeConstant.RECEIVED));
+ message.setSender(Message.SYSTEM_SENDER);
+ message.setReceiver(Message.BROADCAST_RECEIVER);
+ message.setPayload(payload);
+ message.setTime(time);
+ return message;
+ }
+
+ /**
+ * Converts a message from a json object.
+ *
+ * @param jsonObject A json object of message.
+ * @param sessionUser A string of current client username.
+ * @return A converted Message object.
+ */
+ private static Message fromJsonObject(JsonObject jsonObject, String sessionUser) {
+ String messageId = jsonObject.get("MessageId").getAsString();
+ String sender = jsonObject.get("Sender").getAsString();
+ String receiver = jsonObject.get("Receiver").getAsString();
+ String payload = jsonObject.get("Payload").getAsString();
+ boolean isImage = jsonObject.get("IsImage").getAsBoolean();
+ boolean isRead = jsonObject.get("IsRead").getAsBoolean();
+ int rawType = jsonObject.get("Type").getAsInt();
+ boolean isSelf = sender.equals(sessionUser);
+ boolean isPrivate = MessageTypeUtils.convertCSharpRawTypeToMessageTypeConstant(rawType)
+ == MessageTypeConstant.PRIVATE;
+ long time;
+ try {
+ time = sdf.parse(jsonObject.get("SendTime").getAsString()).getTime() + utcOffset;
+ } catch (Exception e) {
+ time = 0;
+ }
+
+ int messageType = MessageTypeUtils.calculateMessageType(isImage, isSelf, isPrivate, isRead);
+
+ Message message = new Message(messageId, messageType);
+ message.setSender(sender);
+ message.setReceiver(receiver);
+ message.setPayload(payload);
+ message.setTime(time);
+
+ return message;
+ }
+
+ /**
+ * Parse a list of history messages from a serialized JSON string.
+ *
+ * @param serializedString A JSON string.
+ * @param sessionUser A string of current client username.
+ * @return A list of parsed history messages.
+ */
+ public static List parseHistoryMessages(String serializedString, String sessionUser) {
+ List historyMessages = new ArrayList<>();
+ JsonArray jsonArray = gson.fromJson(serializedString, JsonArray.class);
+ for (JsonElement jsonElement : jsonArray) {
+ Message chatMessage = fromJsonObject(jsonElement.getAsJsonObject(), sessionUser);
+ historyMessages.add(chatMessage);
+ }
+ return historyMessages;
+ }
+
+ /**
+ * Serializes a list of history messages to a JSON string.
+ *
+ * @param messages A list of history messages.
+ * @return A serialized JSON string.
+ */
+ public static String serializeHistoryMessages(List messages) {
+ JsonArray jsonArray = new JsonArray();
+ for (Message message : messages) {
+ /* We only serialize and store non-system messages. */
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK)
+ != MessageTypeConstant.SYSTEM) {
+ JsonObject jsonObject = toJsonObject(message);
+ jsonArray.add(jsonObject);
+ }
+
+ }
+
+ return jsonArray.toString();
+ }
+
+ /**
+ * Converts a json object from a message.
+ *
+ * @param message A message to convert.
+ * @return A converted json object.
+ */
+ private static JsonObject toJsonObject(Message message) {
+ JsonObject jsonObject = new JsonObject();
+ jsonObject.add("MessageId",gson.toJsonTree(message.getMessageId()));
+ jsonObject.add("Sender",gson.toJsonTree(message.getSender()));
+ jsonObject.add("Receiver",gson.toJsonTree(message.getReceiver()));
+ if (message.isImage()) {
+ jsonObject.add("Payload",gson.toJsonTree(""));
+ } else {
+ jsonObject.add("Payload",gson.toJsonTree(message.getPayload()));
+ }
+ jsonObject.add("IsRead",gson.toJsonTree(message.isRead()));
+ jsonObject.add("IsImage",gson.toJsonTree(message.isImage()));
+ Date date = new Date(message.getTime() - utcOffset);
+ jsonObject.add("SendTime", gson.toJsonTree(sdf.format(date)));
+ if (message.getReceiver().equals(Message.BROADCAST_RECEIVER)) {
+ jsonObject.add("Type", gson.toJsonTree(2));
+ } else if (message.getSender().equals(Message.SYSTEM_SENDER)) {
+ jsonObject.add("Type", gson.toJsonTree(1));
+ } else {
+ jsonObject.add("Type", gson.toJsonTree(0));
+ }
+ return jsonObject;
+ }
+
+ /**
+ * Encodes a bitmap to base64 string.
+ *
+ * @param bmp A bitmap to encode.
+ * @return The encoded base64 string.
+ */
+ public static String encodeToBase64(Bitmap bmp) {
+ /* Empty by default */
+ String messageImageContent = "";
+
+ try (ByteArrayOutputStream stream = new ByteArrayOutputStream()) {
+ bmp.compress(Bitmap.CompressFormat.JPEG, 25, stream);
+ byte[] byteArray = stream.toByteArray();
+ messageImageContent = Base64.getEncoder().encodeToString(byteArray);
+ } catch (IOException ioe) {
+ Log.e("createImageMessage", ioe.getLocalizedMessage());
+ }
+
+ return messageImageContent;
+ }
+
+ /**
+ * Decodes a base64 string to a bitmap object.
+ *
+ * @param payload The base64 string to decode.
+ * @return The decoded image bitmap.
+ */
+ public static Bitmap decodeToBitmap(String payload) {
+ byte[] byteArray = Base64.getDecoder().decode(payload);
+ return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.length);
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/MessageTypeConstant.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/MessageTypeConstant.java
new file mode 100644
index 00000000..5b573a7a
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/model/entity/MessageTypeConstant.java
@@ -0,0 +1,39 @@
+package com.signalr.androidchatroom.model.entity;
+
+/**
+ * Defines constants representing message types.
+ * 0000 0000
+ * ^^ ^^^^
+ * Use lower 6 bits of a int variable to represent complete information about message type.
+ *
+ * 1. Message type: {System, Broadcast, Private}
+ * 0000 0000
+ * ^^
+ * 2. Message content: {Text, Image}
+ * 0000 0000
+ * ^
+ * 3. Message status: {Received, Sending, Sent, Timeout, Read}
+ * 0000 0000
+ * ^^ ^
+ */
+public class MessageTypeConstant {
+
+ /* MESSAGE_TYPE_MASK 0000 0011 */
+ public static final int MESSAGE_TYPE_MASK = 0x3;
+ public static final int SYSTEM = 0x0;
+ public static final int BROADCAST = 0x1;
+ public static final int PRIVATE = 0x2;
+
+ /* MESSAGE_CONTENT_MASK 0000 0100 */
+ public static final int MESSAGE_CONTENT_MASK = 0x4;
+ public static final int TEXT = 0x0;
+ public static final int IMAGE = 0x4;
+
+ /* MESSAGE_STATUS_MASK 0011 1000 */
+ public static final int MESSAGE_STATUS_MASK = 0x38;
+ public static final int RECEIVED = 0x0;
+ public static final int SENDING = 0x8;
+ public static final int SENT = 0x10;
+ public static final int TIMEOUT = 0x18;
+ public static final int READ = 0x20;
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/BasePresenter.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/BasePresenter.java
new file mode 100644
index 00000000..0a83f3d2
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/BasePresenter.java
@@ -0,0 +1,39 @@
+package com.signalr.androidchatroom.presenter;
+
+import android.app.Activity;
+
+import com.signalr.androidchatroom.model.BaseModel;
+import com.signalr.androidchatroom.view.BaseFragment;
+
+/**
+ * Base presenter component for Model-View-Presenter design
+ * @param Fragment (View)
+ * @param Model
+ */
+public abstract class BasePresenter {
+ protected F mBaseFragment;
+ protected M mBaseModel;
+
+ public BasePresenter(F baseFragment, Activity activity) {
+ attachFragment(baseFragment);
+ createModel(activity);
+ }
+
+ public void attachFragment(F baseFragment) {
+ mBaseFragment = baseFragment;
+ }
+
+ public void detach() {
+ if (mBaseFragment != null) {
+ mBaseFragment.detach();
+ mBaseFragment = null;
+ }
+
+ if (mBaseModel != null) {
+ mBaseModel.detach();
+ mBaseModel = null;
+ }
+ }
+
+ public abstract void createModel(Activity activity);
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/ChatPresenter.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/ChatPresenter.java
new file mode 100644
index 00000000..97cabdef
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/ChatPresenter.java
@@ -0,0 +1,360 @@
+package com.signalr.androidchatroom.presenter;
+
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.util.Log;
+
+import com.signalr.androidchatroom.R;
+import com.signalr.androidchatroom.activity.MainActivity;
+import com.signalr.androidchatroom.contract.ChatContract;
+import com.signalr.androidchatroom.model.ChatModel;
+import com.signalr.androidchatroom.model.entity.Message;
+import com.signalr.androidchatroom.model.entity.MessageFactory;
+import com.signalr.androidchatroom.model.entity.MessageTypeConstant;
+import com.signalr.androidchatroom.util.MessageTypeUtils;
+import com.signalr.androidchatroom.util.SimpleCallback;
+import com.signalr.androidchatroom.view.ChatFragment;
+import com.signalr.androidchatroom.view.ScrollDirection;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.stream.Collectors;
+
+/**
+ * Presenter component responsible for chatting.
+ */
+public class ChatPresenter extends BasePresenter implements ChatContract.Presenter {
+ private static final String TAG = "ChatPresenter";
+ private static final long SENDING_TIMEOUT_THRESHOLD = 5000;
+ private static final long NO_DELAY = 0;
+
+ /* User session information */
+ private final String username;
+
+ /* List of local messages */
+ private List messages = new ArrayList<>();
+
+ /* State variable for pull history message callback direction */
+ private ScrollDirection callbackDirection = ScrollDirection.FINGER_UP;
+
+ /* Timer thread for timing out messages */
+ private final Timer timeOutSendingMessagesTimer;
+
+ public ChatPresenter(ChatFragment chatFragment, String username, Activity activity) {
+ super(chatFragment, activity);
+ this.username = username;
+
+ mBaseFragment.activateListeners();
+ mBaseFragment.configureRecyclerView(messages, this);
+
+ timeOutSendingMessagesTimer = new Timer();
+ timeOutSendingMessagesTimer.scheduleAtFixedRate(new TimerTask() {
+ @Override
+ public void run() {
+ timeOutSendingMessages();
+ }
+ }, NO_DELAY, SENDING_TIMEOUT_THRESHOLD);
+
+ restoreOrPullHistoryMessages();
+ }
+
+ @Override
+ public void createModel(Activity activity) {
+ mBaseModel = new ChatModel(this, (MainActivity) activity);
+ }
+
+ private void timeOutSendingMessages() {
+ boolean needUpdate = false;
+ for (Message message : messages) {
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_STATUS_MASK)
+ == MessageTypeConstant.SENDING) {
+ /* If a message is sending and its sending time is
+ * greater than SENDING_TIMEOUT_THRESHOLD, set it
+ * as a timed out message
+ */
+ if (System.currentTimeMillis() - message.getTime() > SENDING_TIMEOUT_THRESHOLD) {
+ message.timeout();
+ needUpdate = true;
+ }
+ }
+
+ }
+
+ if (needUpdate) {
+ /* Set view messages */
+ mBaseFragment.setMessages(messages, ScrollDirection.FINGER_UP);
+ }
+ }
+
+ /**
+ * Restore history messages from SharedPreference or pull from server
+ */
+ private void restoreOrPullHistoryMessages() {
+ Context context = mBaseFragment.getContext();
+ String storedJsonMessages = context
+ .getSharedPreferences(context.getString(R.string.saved_messages_key), Context.MODE_PRIVATE)
+ .getString(context.getString(R.string.saved_messages_key), null);
+
+ if (storedJsonMessages == null || "[]".equals(storedJsonMessages)) {
+ /* Pull History Messages */
+ Log.d(TAG, "First Login: Pulling History messages.");
+ mBaseModel.pullHistoryMessages(calculateUntilTime());
+ } else {
+ /* Restore History Messages */
+ Log.d(TAG, "First Login: Restoring history messages.");
+ messages = MessageFactory.parseHistoryMessages(storedJsonMessages, username);
+ mBaseFragment.setMessages(messages, ScrollDirection.FINGER_UP);
+ }
+ }
+
+ @Override
+ public void saveHistoryMessages() {
+ Context context = mBaseFragment.getContext();
+ SharedPreferences.Editor editor = context
+ .getSharedPreferences(context.getString(R.string.saved_messages_key), Context.MODE_PRIVATE)
+ .edit();
+
+ editor.putString(context.getString(R.string.saved_messages_key), MessageFactory.serializeHistoryMessages(messages))
+ .apply();
+ }
+
+ @Override
+ public void sendTextMessage(String sender, String receiver, String payload) {
+ Message message;
+ if (receiver.length() == 0) {
+ message = MessageFactory
+ .createSendingTextBroadcastMessage(sender, payload, System.currentTimeMillis());
+ } else {
+ message = MessageFactory
+ .createSendingTextPrivateMessage(sender, receiver, payload, System.currentTimeMillis());
+ }
+
+ messages.add(message);
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK)
+ == MessageTypeConstant.PRIVATE) {
+ mBaseModel.sendPrivateMessage(message);
+ } else if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK)
+ == MessageTypeConstant.BROADCAST) {
+ mBaseModel.sendBroadcastMessage(message);
+ }
+
+ mBaseFragment.setMessages(messages, ScrollDirection.FINGER_UP);
+ }
+
+ @Override
+ public void sendImageMessage(String sender, String receiver, Bitmap image) {
+ SimpleCallback callback = new SimpleCallback() {
+ @Override
+ public void onSuccess(Message message) {
+ messages.add(message);
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK)
+ == MessageTypeConstant.PRIVATE) {
+ mBaseModel.sendPrivateMessage(message);
+ } else if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK)
+ == MessageTypeConstant.BROADCAST) {
+ mBaseModel.sendBroadcastMessage(message);
+ }
+ mBaseFragment.setMessages(messages, ScrollDirection.FINGER_UP);
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+
+ }
+ };
+
+ if (receiver.length() == 0) {
+ MessageFactory
+ .createSendingImageBroadcastMessage(sender, image, System.currentTimeMillis(), callback);
+ } else {
+ MessageFactory
+ .createSendingImagePrivateMessage(sender, receiver, image, System.currentTimeMillis(), callback);
+ }
+
+ }
+
+ @Override
+ public void resendMessage(String messageId) {
+ Message message = getMessageWithId(messageId);
+ if (message == null) {
+ return;
+ }
+
+ message.setTime(System.currentTimeMillis());
+ message.setMessageType(MessageTypeUtils
+ .setStatus(message.getMessageType(), MessageTypeConstant.SENDING));
+
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK)
+ == MessageTypeConstant.BROADCAST) {
+ mBaseModel.sendBroadcastMessage(message);
+ } else if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK)
+ == MessageTypeConstant.PRIVATE) {
+ mBaseModel.sendPrivateMessage(message);
+ }
+ }
+
+ @Override
+ public void sendMessageRead(String messageId) {
+ mBaseModel.sendMessageRead(messageId);
+ }
+
+
+ @Override
+ public void receiveMessageAck(String messageId, long receivedTimeInLong) {
+ Message message = getMessageWithId(messageId);
+ message.ack(receivedTimeInLong);
+ mBaseFragment.setMessages(messages, ScrollDirection.KEEP_POSITION);
+ }
+
+ @Override
+ public void receiveMessageRead(String messageId) {
+ for (Message message : messages) {
+ if (message.getMessageId().equals(messageId)) {
+ message.read();
+ break;
+ }
+ }
+ /* Set view messages */
+ mBaseFragment.setMessages(messages, ScrollDirection.KEEP_POSITION);
+ }
+
+ @Override
+ public void receiveImageContent(String messageId, Bitmap bmp) {
+ Message message = getMessageWithId(messageId);
+ if (message != null) {
+ message.setPayload("");
+ message.setBmp(bmp);
+ /* Set view messages */
+ mBaseFragment.setMessages(messages, ScrollDirection.KEEP_POSITION);
+ }
+ }
+
+ @Override
+ public void pullHistoryMessages(ScrollDirection callbackDirection) {
+ this.callbackDirection = callbackDirection;
+ mBaseModel.pullHistoryMessages(calculateUntilTime());
+ }
+
+ private long calculateUntilTime() {
+ long untilTime = System.currentTimeMillis();
+ for (Message message : messages) {
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK)
+ != MessageTypeConstant.SYSTEM) {
+ untilTime = message.getTime();
+ break;
+ }
+ }
+ return untilTime;
+ }
+
+ @Override
+ public void pullImageContent(String messageId) {
+ mBaseModel.pullImageContent(messageId);
+ }
+
+ @Override
+ public void requestLogout() {
+ mBaseModel.logout();
+ }
+
+ @Override
+ public void confirmLogout(boolean isForced) {
+ mBaseFragment.setLogout(isForced);
+ }
+
+
+ @Override
+ public void addMessage(@NotNull Message message) {
+ /* Check for duplicated message */
+ boolean isDuplicateMessage = checkForDuplicatedMessage(message.getMessageId());
+
+ /* If not duplicated, create ChatMessage according to parameters */
+ if (!isDuplicateMessage) {
+ messages.add(message);
+
+ /* Tell the server the message was read */
+ sendMessageRead(message);
+ }
+
+ /* Sort messages by send time */
+ messages.sort((m1, m2) -> (int) (m1.getTime() - m2.getTime()));
+
+ /* Set view messages */
+ mBaseFragment.setMessages(messages, ScrollDirection.FINGER_UP);
+ }
+
+ @Override
+ public void addMessage(Message message, String ackId) {
+ /* Send back ack */
+ mBaseModel.sendAck(ackId);
+
+ addMessage(message);
+ }
+
+ @Override
+ public void addAllMessages(List receivedMessages) {
+ /* Record all messages for now */
+ Set existedMessageIds = this.messages.stream().map(Message::getMessageId).collect(Collectors.toSet());
+
+ /* Iterate through message list */
+ for (Message message : receivedMessages) {
+ if (!existedMessageIds.contains(message.getMessageId())) {
+ /* If found a new message, add it to message list */
+ this.messages.add(message);
+ existedMessageIds.add(message.getMessageId());
+
+ /* Tell the server the message was read */
+ sendMessageRead(message);
+ }
+ }
+
+ /* Sort messages by send time */
+ messages.sort((m1, m2) -> (int) (m1.getTime() - m2.getTime()));
+
+ /* Set view messages */
+ mBaseFragment.setMessages(messages, callbackDirection);
+ }
+
+ private void sendMessageRead(Message message) {
+ if (isUnreadReceivedPrivateMessage(message)) {
+ mBaseModel.sendMessageRead(message.getMessageId());
+ }
+ }
+
+ private boolean isUnreadReceivedPrivateMessage(Message message) {
+ return !message.isRead() &&
+ ((message.getMessageType() & MessageTypeConstant.MESSAGE_STATUS_MASK) == MessageTypeConstant.RECEIVED)
+ && ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK) == MessageTypeConstant.PRIVATE);
+ }
+
+
+ private boolean checkForDuplicatedMessage(String messageId) {
+ boolean isDuplicateMessage = false;
+ for (Message message : messages) {
+ if (message.getMessageId().equals(messageId)) {
+ isDuplicateMessage = true;
+ break;
+ }
+ }
+ return isDuplicateMessage;
+ }
+
+ private Message getMessageWithId(String messageId) {
+ for (Message message : messages) {
+ if (message.getMessageId().equals(messageId)) {
+ return message;
+ }
+ }
+
+ return null;
+ }
+
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/LoginPresenter.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/LoginPresenter.java
new file mode 100644
index 00000000..31975396
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/presenter/LoginPresenter.java
@@ -0,0 +1,86 @@
+package com.signalr.androidchatroom.presenter;
+
+import android.app.Activity;
+
+import com.signalr.androidchatroom.activity.MainActivity;
+import com.signalr.androidchatroom.contract.LoginContract;
+import com.signalr.androidchatroom.model.LoginModel;
+import com.signalr.androidchatroom.util.SimpleCallback;
+import com.signalr.androidchatroom.view.LoginFragment;
+
+/**
+ * Presenter component responsible for logging in.
+ */
+public class LoginPresenter extends BasePresenter implements LoginContract.Presenter {
+
+ public LoginPresenter(LoginFragment loginFragment, MainActivity mainActivity) {
+ super(loginFragment, mainActivity);
+ }
+
+ @Override
+ public void createModel(Activity activity) {
+ mBaseModel = new LoginModel(this, (MainActivity) activity);
+ }
+
+ @Override
+ public void signIn(SimpleCallback refreshUiCallback) {
+ /* Connect to Azure Notification Hub and refresh a device Uuid */
+ mBaseModel.refreshDeviceUuid();
+
+ /* Create Client Auth App first */
+ mBaseModel.createClientApplication(new SimpleCallback() {
+ @Override
+ public void onSuccess(Void aVoid) {
+ /* If create succeeded, sign into AAD with client auth app */
+ mBaseModel.signIn(new SimpleCallback() {
+ @Override
+ public void onSuccess(String username) {
+ /*
+ * If sign into AAD was successful, must have a valid ID Token.
+ * Use the ID Token to send POST request to App Server.
+ * (Any POST request without a valid token will be rejected by App Server)
+ */
+ enterChatRoom(username, refreshUiCallback);
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+ refreshUiCallback.onError(errorMessage);
+ }
+ });
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+ refreshUiCallback.onError(errorMessage);
+ }
+ });
+ }
+
+ private void enterChatRoom(String username, SimpleCallback refreshUiCallback) {
+ /* Acquire id token first */
+ mBaseModel.acquireIdToken(new SimpleCallback() {
+ @Override
+ public void onSuccess(String idToken) {
+ /* Succeeded in acquiring id token */
+ mBaseModel.enterChatRoom(idToken, username, new SimpleCallback() {
+ @Override
+ public void onSuccess(Void v) {
+ mBaseFragment.setLogin(username);
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+ refreshUiCallback.onError(errorMessage);
+ }
+ });
+ }
+
+ @Override
+ public void onError(String errorMessage) {
+ refreshUiCallback.onError(errorMessage);
+ }
+ });
+
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/AuthenticationService.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/AuthenticationService.java
new file mode 100644
index 00000000..4efe2106
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/AuthenticationService.java
@@ -0,0 +1,139 @@
+package com.signalr.androidchatroom.service;
+
+import android.app.Activity;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.microsoft.identity.client.AuthenticationCallback;
+import com.microsoft.identity.client.IAccount;
+import com.microsoft.identity.client.IAuthenticationResult;
+import com.microsoft.identity.client.IPublicClientApplication;
+import com.microsoft.identity.client.ISingleAccountPublicClientApplication;
+import com.microsoft.identity.client.PublicClientApplication;
+import com.microsoft.identity.client.SilentAuthenticationCallback;
+import com.microsoft.identity.client.exception.MsalException;
+import com.signalr.androidchatroom.R;
+import com.signalr.androidchatroom.util.SimpleCallback;
+
+/**
+ * Authentication layer that interacts with Azure Active Directory using MSAL
+ * See https://docs.microsoft.com/en-us/azure/active-directory/develop/tutorial-v2-android#integrate-with-microsoft-authentication-library
+ * for reference.
+ */
+public class AuthenticationService {
+ private static final String TAG = "AuthenticationService";
+
+ /* We only need the very basic scope. You can always add more. */
+ private final static String[] SCOPES = {"User.Read"};
+
+ /* You can find the value at Azure Portal -> Azure Active Directory -> App Registration
+ * -> Endpoints.
+ * For general purpose sign in (Microsoft/Outlook/Live),
+ * use https://login.microsoftonline.com/common
+ */
+ private final static String AUTHORITY = "https://login.microsoftonline.com/common";
+ private static ISingleAccountPublicClientApplication mSingleAccountApp;
+
+ public static void createClientApplication(Context context, SimpleCallback callback) {
+ PublicClientApplication.createSingleAccountPublicClientApplication(context,
+ R.raw.auth_config_single_account, new IPublicClientApplication.ISingleAccountApplicationCreatedListener() {
+ @Override
+ public void onCreated(ISingleAccountPublicClientApplication application) {
+ Log.d(TAG, "Client Application Created.");
+ mSingleAccountApp = application;
+ callback.onSuccess(null);
+ }
+
+ @Override
+ public void onError(MsalException exception) {
+ Log.e(TAG, "Client Application Creation Failed.");
+ callback.onError(exception.getMessage());
+ }
+ });
+ }
+
+ public static void signOut() {
+ mSingleAccountApp.signOut(new ISingleAccountPublicClientApplication.SignOutCallback() {
+ @Override
+ public void onSignOut() {
+
+ }
+
+ @Override
+ public void onError(@NonNull MsalException e) {
+
+ }
+ });
+ }
+
+ public static void signIn(Activity activity, SimpleCallback usernameCallback) {
+ mSingleAccountApp.signIn(activity, null, SCOPES, new AuthenticationCallback() {
+ @Override
+ public void onCancel() {
+ Log.d(TAG, "MSAL signIn cancelled.");
+ usernameCallback.onError("Signing in Cancelled.");
+ }
+
+ @Override
+ public void onSuccess(IAuthenticationResult iAuthenticationResult) {
+ /* A successful signing in can guarantee you a valid username */
+ Log.d(TAG, "MSAL signIn succeeded.");
+ usernameCallback.onSuccess(iAuthenticationResult.getAccount().getUsername());
+ }
+
+ @Override
+ public void onError(MsalException e) {
+ Log.e(TAG, "MSAL signIn error.");
+ usernameCallback.onError(e.getMessage());
+ }
+ });
+ }
+
+ public static void loadActiveAccountOrSignIn(Activity activity, SimpleCallback usernameCallback) {
+ mSingleAccountApp.getCurrentAccountAsync(new ISingleAccountPublicClientApplication.CurrentAccountCallback() {
+ @Override
+ public void onAccountLoaded(@Nullable IAccount activeAccount) {
+ /* Exists a logged in account */
+ if (activeAccount == null) {
+ signIn(activity, usernameCallback);
+ } else {
+ usernameCallback.onSuccess(activeAccount.getUsername());
+ }
+
+ }
+
+ @Override
+ public void onAccountChanged(@Nullable IAccount priorAccount, @Nullable IAccount currentAccount) {
+ if (currentAccount == null) {
+ signIn(activity, usernameCallback);
+ }
+ }
+
+ @Override
+ public void onError(@NonNull MsalException exception) {
+ Log.e(TAG, "Load Existing Account Error.");
+ usernameCallback.onError(exception.getMessage());
+ }
+ });
+
+ }
+
+ public static void acquireIdToken(SimpleCallback idTokenCallback) {
+ mSingleAccountApp.acquireTokenSilentAsync(SCOPES, AUTHORITY, new SilentAuthenticationCallback() {
+ @Override
+ public void onSuccess(IAuthenticationResult iAuthenticationResult) {
+ /* Passing the ID Token back to model layer */
+ idTokenCallback.onSuccess(iAuthenticationResult.getAccount().getIdToken());
+ }
+
+ @Override
+ public void onError(MsalException e) {
+ idTokenCallback.onSuccess(e.getMessage());
+ }
+ });
+ }
+
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/FirebaseService.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/FirebaseService.java
new file mode 100644
index 00000000..743e3254
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/FirebaseService.java
@@ -0,0 +1,101 @@
+package com.signalr.androidchatroom.service;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+
+import com.google.firebase.messaging.FirebaseMessagingService;
+import com.google.firebase.messaging.RemoteMessage;
+import com.signalr.androidchatroom.activity.MainActivity;
+
+import java.util.Iterator;
+
+/**
+ * FirebaseService class
+ * See https://docs.microsoft.com/en-us/azure/notification-hubs/notification-hubs-android-push-notification-google-fcm-get-started#test-send-notification-from-the-notification-hub
+ */
+public class FirebaseService extends FirebaseMessagingService {
+
+ public static final String NOTIFICATION_CHANNEL_ID = "id_chatroom";
+ public static final String NOTIFICATION_CHANNEL_NAME = "ChatRoom Channel";
+ public static final String NOTIFICATION_CHANNEL_DESCRIPTION = "ChatRoom Channel Description";
+ public static final int NOTIFICATION_ID = 1;
+ private static final String TAG = "FirebaseService";
+ private NotificationManager notificationManager;
+
+ public static void createNotificationChannel(Context context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ NOTIFICATION_CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_HIGH);
+ channel.setDescription(NOTIFICATION_CHANNEL_DESCRIPTION);
+ channel.setShowBadge(true);
+
+ NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
+ notificationManager.createNotificationChannel(channel);
+ }
+ }
+
+ @Override
+ public void onNewToken(@NonNull String s) {
+ super.onNewToken(s);
+ }
+
+ @Override
+ public void onMessageReceived(RemoteMessage remoteMessage) {
+ Log.d(TAG, "From: " + remoteMessage.getFrom());
+
+ /* Check if message contains a notification payload */
+ String notificationTitle = null, notificationBody = null;
+ if (remoteMessage.getNotification() != null) {
+ Log.d(TAG, "Message Notification Body: " + remoteMessage.getNotification().getBody());
+ notificationBody = remoteMessage.getNotification().getBody();
+ } else {
+ Iterator dataPayloadIterator = remoteMessage.getData().values().iterator();
+ notificationTitle = dataPayloadIterator.next();
+ notificationBody = dataPayloadIterator.next();
+ }
+
+ /* When MainActivity is invisible, show notification */
+ if (!MainActivity.getActiveInstance().isVisible()) {
+ showNotification(notificationTitle, notificationBody);
+ }
+
+ }
+
+ private void showNotification(String title, String body) {
+
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ notificationManager = (NotificationManager)
+ this.getSystemService(Context.NOTIFICATION_SERVICE);
+
+ PendingIntent contentIntent = PendingIntent.getActivity(this, 0,
+ intent, PendingIntent.FLAG_ONE_SHOT);
+
+ NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(
+ this,
+ NOTIFICATION_CHANNEL_ID)
+ .setContentText(body)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setSmallIcon(android.R.drawable.ic_popup_reminder)
+ .setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL)
+ .setAutoCancel(true);
+
+ if (title != null) {
+ notificationBuilder.setContentTitle(title);
+ }
+
+ notificationBuilder.setContentIntent(contentIntent);
+ notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build());
+ }
+}
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/NotificationService.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/NotificationService.java
new file mode 100644
index 00000000..a74c940f
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/NotificationService.java
@@ -0,0 +1,66 @@
+package com.signalr.androidchatroom.service;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.IBinder;
+import android.util.Log;
+
+import com.google.android.gms.tasks.OnSuccessListener;
+import com.google.firebase.iid.FirebaseInstanceId;
+import com.google.firebase.iid.InstanceIdResult;
+import com.signalr.androidchatroom.R;
+import com.microsoft.windowsazure.messaging.NotificationHub;
+
+import java.util.Arrays;
+import java.util.UUID;
+
+/**
+ * Service that handles Azure Notification Hub interactions
+ * See https://docs.microsoft.com/en-us/azure/notification-hubs/notification-hubs-android-push-notification-google-fcm-get-started#test-send-notification-from-the-notification-hub
+ */
+public class NotificationService extends Service {
+ private static final String TAG = "NotificationService";
+
+ private final String deviceUuid = UUID.randomUUID().toString();
+
+ /* Service binder */
+ private final IBinder notificationServiceBinder = new NotificationService.NotificationServiceBinder();
+ private String deviceToken;
+ private String registrationId;
+ private NotificationHub notificationHub;
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ FirebaseInstanceId.getInstance().getInstanceId().addOnSuccessListener(new OnSuccessListener() {
+ @Override
+ public void onSuccess(InstanceIdResult instanceIdResult) {
+ deviceToken = instanceIdResult.getToken();
+ notificationHub = new NotificationHub(getString(R.string.azure_notification_hub_name),
+ getString(R.string.azure_notification_hub_connection_string), NotificationService.this);
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ Log.d(TAG, String.format("Register with deviceToken: %s; tag: %s", deviceToken, deviceUuid));
+ registrationId = notificationHub.register(deviceToken, deviceUuid).getRegistrationId();
+ } catch (Exception e) {
+ Log.d(TAG, Arrays.toString(e.getStackTrace()));
+ }
+ }
+ }.start();
+ }
+ });
+ return notificationServiceBinder;
+ }
+
+ public String getDeviceUuid() {
+ return deviceUuid;
+ }
+
+ public class NotificationServiceBinder extends Binder {
+ public NotificationService getService() {
+ return NotificationService.this;
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/SignalRService.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/SignalRService.java
new file mode 100644
index 00000000..0caba611
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/service/SignalRService.java
@@ -0,0 +1,211 @@
+package com.signalr.androidchatroom.service;
+
+import android.util.Log;
+
+import com.microsoft.signalr.Action1;
+import com.microsoft.signalr.Action2;
+import com.microsoft.signalr.Action3;
+import com.microsoft.signalr.Action7;
+import com.microsoft.signalr.HubConnection;
+import com.microsoft.signalr.HubConnectionBuilder;
+import com.microsoft.signalr.HubConnectionState;
+import com.signalr.androidchatroom.util.SimpleCallback;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+import io.reactivex.CompletableObserver;
+import io.reactivex.Single;
+import io.reactivex.SingleObserver;
+import io.reactivex.annotations.NonNull;
+import io.reactivex.disposables.Disposable;
+import io.reactivex.schedulers.Schedulers;
+
+/**
+ * SignalR layer that directly communicates with remote SignalR service
+ */
+public class SignalRService {
+ private static final String TAG = "SignalRService";
+
+ private static final String azureAppServiceUrl = "YOUR_APP_SERVICE_URL";
+ private static final String localDebugUrl = "http://10.0.2.2:5000/chat";
+ private static final String serverUrl = azureAppServiceUrl;
+
+ private static String sUsername;
+ private static String sDeviceUuid;
+
+ private static HubConnection hubConnection;
+ private static Timer reconnectTimer;
+
+ public static void startHubConnection(SimpleCallback callback, String idToken) {
+ /* Double-if synchronized block to ensure only one thread can create the singleton
+ * HubConnection.
+ */
+ if (hubConnection == null) {
+ synchronized (SignalRService.class) {
+ if (hubConnection == null) {
+ hubConnection = HubConnectionBuilder
+ .create(serverUrl)
+ .withAccessTokenProvider(Single.defer(() -> Single.just(idToken)))
+ .build();
+ }
+ }
+ }
+ /* After creating HubConnection, start it if it's not CONNECTED. */
+ if (hubConnection.getConnectionState() != HubConnectionState.CONNECTED) {
+ hubConnection.start().subscribeOn(Schedulers.io())
+ .subscribe(new CompletableObserver() {
+ @Override
+ public void onSubscribe(@NonNull Disposable d) {
+
+ }
+
+ @Override
+ public void onComplete() {
+ callback.onSuccess(null);
+ }
+
+ @Override
+ public void onError(@NonNull Throwable e) {
+ callback.onError(e.getMessage());
+ }
+ });
+ } else {
+ /* Directly call onSuccess callback if HubConnection is already CONNECTED */
+ callback.onSuccess(null);
+ }
+ }
+
+ private static void reconnect() {
+ if (hubConnection.getConnectionState() != HubConnectionState.CONNECTED) {
+ hubConnection.start().subscribe(new CompletableObserver() {
+ @Override
+ public void onSubscribe(@NotNull Disposable d) {
+
+ }
+
+ @Override
+ public void onComplete() {
+
+ }
+
+ @Override
+ public void onError(@NotNull Throwable e) {
+ Log.e(TAG, e.toString());
+ }
+ });
+ } else {
+ /*
+ * If connected, must be in an active session. Directly call TouchServer
+ * TouchServer method has two purpose:
+ * 1. As a stay alive message
+ * 2. Update sDeviceUuid in realtime in case it has changed since last method call.
+ * This might happen when the app crashes.
+ */
+ hubConnection.send("TouchServer", sDeviceUuid, sUsername);
+ }
+ }
+
+ public static void stopHubConnection() {
+ if (hubConnection != null && hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ synchronized (SignalRService.class) {
+ hubConnection.stop();
+ }
+ }
+ }
+
+ public static void registerServerCallback(String target, Action1 action, Class clazz) {
+ hubConnection.on(target, action, clazz);
+ }
+
+ public static void registerServerCallback(String target, Action2 action, Class clazz1, Class clazz2) {
+ hubConnection.on(target, action, clazz1, clazz2);
+ }
+
+ public static void registerServerCallback(String target, Action3 action, Class clazz1, Class clazz2, Class clazz3) {
+ hubConnection.on(target, action, clazz1, clazz2, clazz3);
+ }
+
+ public static void registerServerCallback(String target, Action7 action, Class clazz1, Class clazz2, Class clazz3, Class clazz4, Class clazz5, Class clazz6, Class clazz7) {
+ hubConnection.on(target, action, clazz1, clazz2, clazz3, clazz4, clazz5, clazz6, clazz7);
+ }
+
+ public static Single login(String deviceUuid, String username) {
+ if (hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ sDeviceUuid = deviceUuid;
+ sUsername = username;
+ return hubConnection.invoke(String.class, "EnterChatRoom", sDeviceUuid, sUsername);
+ }
+ return null;
+ }
+
+ public static void startReconnectTimer() {
+ reconnectTimer = new Timer();
+ reconnectTimer.scheduleAtFixedRate(new TimerTask() {
+ @Override
+ public void run() {
+ reconnect();
+ }
+ }, 0, 5000);
+ }
+
+ public static void stopReconnectTimer() {
+ if (reconnectTimer != null) {
+ reconnectTimer.cancel();
+ reconnectTimer = null;
+ }
+ }
+
+ public static Single logout() {
+ if (hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ return hubConnection.invoke(String.class, "LeaveChatRoom", sDeviceUuid, sUsername);
+ }
+ return null;
+ }
+
+ public static void sendBroadcastMessage(String messageId, String sender, String payload, boolean isImage) {
+ Log.d(TAG, "sendBroadcastMessage");
+ if (hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ hubConnection.send("OnBroadcastMessageReceived",
+ messageId, sender, payload, isImage);
+ }
+ }
+
+ public static void sendPrivateMessage(String messageId, String sender, String receiver, String payload, boolean isImage) {
+ Log.d(TAG, "sendPrivateMessage");
+ if (hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ hubConnection.send("OnPrivateMessageReceived",
+ messageId, sender, receiver, payload, isImage);
+ }
+ }
+
+ public static void sendMessageRead(String messageId) {
+ if (hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ hubConnection.send("OnReadResponseReceived", messageId, sUsername);
+ }
+ }
+
+ public static void sendAck(String ackId) {
+ if (hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ hubConnection.send("OnAckResponseReceived", ackId, sUsername);
+ }
+ }
+
+ public static void pullHistoryMessages(long untilTimeInLong) {
+ if (hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ hubConnection.send("OnPullHistoryMessagesReceived", sUsername, untilTimeInLong);
+ }
+ }
+
+ public static void pullImageContent(String messageId) {
+ if (hubConnection.getConnectionState() == HubConnectionState.CONNECTED) {
+ hubConnection.send("OnPullImageContentReceived", sUsername, messageId);
+ }
+ }
+
+ public static String getUsername() {
+ return sUsername;
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/util/MessageTypeUtils.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/util/MessageTypeUtils.java
new file mode 100644
index 00000000..e6ef917b
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/util/MessageTypeUtils.java
@@ -0,0 +1,126 @@
+package com.signalr.androidchatroom.util;
+
+import com.signalr.androidchatroom.model.entity.MessageTypeConstant;
+
+/**
+ * Utility class for MessageTypeConstant manipulation.
+ */
+public class MessageTypeUtils {
+
+ /**
+ * Sets the MESSAGE_TYPE bits to type.
+ *
+ * 0000 0000
+ * ^^
+ *
+ * @param messageType The complete int returned after calling Message.getMessageType()
+ * @param type Choose among {MessageTypeConstant.SYSTEM, MessageTypeConstant.BROADCAST,
+ * MessageTypeConstant.PRIVATE}
+ * @return The complete bit of modified messageType.
+ */
+ public static int setType(int messageType, int type) {
+ /* Clear type field */
+ messageType = messageType & (~MessageTypeConstant.MESSAGE_TYPE_MASK);
+
+ /* Set type field */
+ messageType = messageType | type;
+
+ return messageType;
+ }
+
+ /**
+ * Sets the MESSAGE_CONTENT bits to content.
+ *
+ * 0000 0000
+ * ^
+ *
+ * @param messageType The complete int returned after calling Message.getMessageType()
+ * @param content Choose between {MessageTypeConstant.TEXT, MessageTypeConstant.IMAGE}
+ * @return The complete bit of modified messageType.
+ */
+ public static int setContent(int messageType, int content) {
+ /* Clear content field */
+ messageType = messageType & (~MessageTypeConstant.MESSAGE_CONTENT_MASK);
+
+ /* Set content field */
+ messageType = messageType | content;
+
+ return messageType;
+ }
+
+ /**
+ * Sets the MESSAGE_STATUS bits to status.
+ *
+ * 0000 0000
+ * ^^ ^
+ *
+ * @param messageType The complete int returned after calling Message.getMessageType()
+ * @param status Choose among {MessageTypeConstant.RECEIVED, MessageTypeConstant.SENDING,
+ * MessageTypeConstant.SENT, MessageTypeConstant.TIMEOUT, MessageTypeConstant.READ}
+ * @return The complete bit of modified messageType.
+ */
+ public static int setStatus(int messageType, int status) {
+ /* Clear status field */
+ messageType = messageType & (~MessageTypeConstant.MESSAGE_STATUS_MASK);
+
+ /* Set status field */
+ messageType = messageType | status;
+
+ return messageType;
+ }
+
+ /**
+ * Calculate the complete messageType bits given different MessageTypeConstant values.
+ *
+ * @param type Choose among {MessageTypeConstant.SYSTEM, MessageTypeConstant.BROADCAST,
+ * MessageTypeConstant.PRIVATE}
+ * @param content Choose between {MessageTypeConstant.TEXT, MessageTypeConstant.IMAGE}
+ * @param status Choose among {MessageTypeConstant.RECEIVED, MessageTypeConstant.SENDING,
+ * MessageTypeConstant.SENT, MessageTypeConstant.TIMEOUT, MessageTypeConstant.READ}
+ * @return The complete bit of messageType.
+ */
+ public static int calculateMessageType(int type, int content, int status) {
+ return type | content | status;
+ }
+
+ /**
+ * Calculates the complete messageType bits given different flags.
+ *
+ * @param isImage If is an image.
+ * @param isSelf If is send by user him(her)self.
+ * @param isPrivate If is a private message.
+ * @param isRead If is read.
+ * @return The complete bit of messageType.
+ */
+ public static int calculateMessageType(boolean isImage, boolean isSelf, boolean isPrivate, boolean isRead) {
+ int messageType;
+ int type = isPrivate ? MessageTypeConstant.PRIVATE : MessageTypeConstant.BROADCAST;
+ int content = isImage ? MessageTypeConstant.IMAGE : MessageTypeConstant.TEXT;
+ int status = isSelf ? (isRead && isPrivate ? MessageTypeConstant.READ : MessageTypeConstant.SENT) : MessageTypeConstant.RECEIVED;
+ messageType = MessageTypeUtils.calculateMessageType(type, content, status);
+ return messageType;
+ }
+
+ /**
+ * Converts the C# raw type to MessageTypeConstant.
+ *
+ * @param rawType A int of C# rawType passed by JSON string.
+ * @return The corresponding MessageTypeConstant.
+ */
+ public static int convertCSharpRawTypeToMessageTypeConstant(int rawType) {
+ /* In C#'s MessageTypeEnum 0 is private message;
+ * 1 is broadcast message;
+ * 2 is system message.
+ */
+ switch (rawType) {
+ case 0:
+ return MessageTypeConstant.PRIVATE;
+ case 1:
+ return MessageTypeConstant.SYSTEM;
+ case 2:
+ return MessageTypeConstant.BROADCAST;
+ default:
+ throw new RuntimeException("Unresolvable Message Type:" + rawType);
+ }
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/util/SimpleCallback.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/util/SimpleCallback.java
new file mode 100644
index 00000000..7dd00d30
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/util/SimpleCallback.java
@@ -0,0 +1,15 @@
+package com.signalr.androidchatroom.util;
+
+/**
+ * Defines a simple callback wrapper interface.
+ * @param Type of parameter you want to pass into onSuccess method.
+ */
+public interface SimpleCallback {
+ default void onSuccess(T t) {
+
+ }
+
+ default void onError(String errorMessage) {
+
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/BaseFragment.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/BaseFragment.java
new file mode 100644
index 00000000..b76987c4
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/BaseFragment.java
@@ -0,0 +1,13 @@
+package com.signalr.androidchatroom.view;
+
+import androidx.fragment.app.Fragment;
+
+/**
+ * Base class for fragments
+ */
+public class BaseFragment extends Fragment {
+
+ public void detach() {
+
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/ChatFragment.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/ChatFragment.java
new file mode 100644
index 00000000..33fe7206
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/ChatFragment.java
@@ -0,0 +1,217 @@
+package com.signalr.androidchatroom.view;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.navigation.fragment.NavHostFragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import com.signalr.androidchatroom.R;
+import com.signalr.androidchatroom.activity.MainActivity;
+import com.signalr.androidchatroom.contract.ChatContract;
+import com.signalr.androidchatroom.model.entity.Message;
+import com.signalr.androidchatroom.presenter.ChatPresenter;
+import com.signalr.androidchatroom.service.SignalRService;
+import com.signalr.androidchatroom.view.chatrecyclerview.ChatContentAdapter;
+
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.util.List;
+
+import static android.app.Activity.RESULT_OK;
+
+/**
+ * Fragment class for chat related views.
+ */
+public class ChatFragment extends BaseFragment implements ChatContract.View {
+ private static final String TAG = "ChatFragment";
+
+ public static final int RESULT_LOAD_IMAGE = 1;
+
+ private ChatPresenter mChatPresenter;
+ private String username;
+
+ /* View elements and adapters */
+ private EditText chatBoxReceiverEditText;
+ private EditText chatBoxMessageEditText;
+ private Button chatBoxSendButton;
+ private Button chatBoxImageButton;
+ private RecyclerView chatContentRecyclerView;
+ private ChatContentAdapter chatContentAdapter;
+ private SwipeRefreshLayout chatContentSwipeRefreshLayout;
+ private LinearLayoutManager layoutManager;
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ ((MainActivity) context).setChatFragment(this);
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ /* Get passed username */
+ if ((username = getArguments().getString("username")) == null) {
+ username = "EMPTY_PLACEHOLDER";
+ }
+ }
+
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState
+ ) {
+ /* Inflate the layout for this fragment */
+ View view = inflater.inflate(R.layout.fragment_chat, container, false);
+
+ /* Get view element references */
+ chatBoxReceiverEditText = view.findViewById(R.id.edit_chat_receiver);
+ chatBoxMessageEditText = view.findViewById(R.id.edit_chat_message);
+ chatBoxSendButton = view.findViewById(R.id.button_chatbox_send);
+ chatBoxImageButton = view.findViewById(R.id.button_chatbox_image);
+ chatContentRecyclerView = view.findViewById(R.id.recyclerview_chatcontent);
+ chatContentSwipeRefreshLayout = view.findViewById(R.id.swipe_refresh_layout_chatcontent);
+
+ /* Create objects presenter */
+ mChatPresenter = new ChatPresenter(this, username, requireActivity());
+
+ return view;
+ }
+
+ public void configureRecyclerView(List messages, ChatPresenter chatPresenter) {
+ chatContentAdapter = new ChatContentAdapter(messages, this, chatPresenter);
+ layoutManager = new LinearLayoutManager(this.getActivity());
+
+ /* The layout manager should append new messages to end (bottom) */
+ layoutManager.setStackFromEnd(true);
+
+ chatContentRecyclerView.setLayoutManager(layoutManager);
+ chatContentRecyclerView.setAdapter(chatContentAdapter);
+
+ /* Finger swipe down to fetch history messages */
+ chatContentSwipeRefreshLayout.setOnRefreshListener(() ->
+ mChatPresenter.pullHistoryMessages(ScrollDirection.KEEP_POSITION));
+ }
+
+ @Override
+ public void activateListeners() {
+ chatBoxSendButton.setOnClickListener(this::sendButtonOnClickListener);
+ chatBoxImageButton.setOnClickListener(this::imageButtonOnClickListener);
+ }
+
+ @Override
+ public void deactivateListeners() {
+ chatBoxSendButton.setOnClickListener(null);
+ chatBoxImageButton.setOnClickListener(null);
+ }
+
+ @Override
+ public void setMessages(List messages, ScrollDirection direction) {
+ updateRecyclerView(messages, direction);
+ }
+
+ @Override
+ public void setLogout(boolean isForced) {
+ if (isForced) {
+ requireActivity().runOnUiThread(() -> {
+ AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
+ builder.setMessage(R.string.alert_message)
+ .setTitle(R.string.alert_title)
+ .setCancelable(false);
+ builder.setPositiveButton(R.string.alert_ok, (dialog, id) -> {
+ NavHostFragment.findNavController(ChatFragment.this).navigate(R.id.action_ChatFragment_to_LoginFragment);
+ requireActivity().recreate();
+ });
+ AlertDialog dialog = builder.create();
+ dialog.show();
+ });
+ } else {
+ NavHostFragment.findNavController(ChatFragment.this)
+ .navigate(R.id.action_ChatFragment_to_LoginFragment);
+ }
+ }
+
+ @Override
+ public void onActivityResult(int reqCode, int resultCode, Intent data) {
+ super.onActivityResult(reqCode, resultCode, data);
+ if (resultCode == RESULT_OK) {
+ try {
+ Uri imageUri = data.getData();
+ InputStream imageStream = requireActivity().getContentResolver().openInputStream(imageUri);
+ Bitmap selectedImage = BitmapFactory.decodeStream(imageStream);
+
+ mChatPresenter.sendImageMessage(username, chatBoxReceiverEditText.getText().toString(), selectedImage);
+ } catch (FileNotFoundException e) {
+ e.printStackTrace();
+ Toast.makeText(getContext(), R.string.image_picking_failed, Toast.LENGTH_LONG).show();
+ }
+ } else {
+ Toast.makeText(getContext(), R.string.no_image_picked, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void sendButtonOnClickListener(View view) {
+ if (chatBoxMessageEditText.getText().length() > 0) { /* Empty message not allowed */
+ /* Call presenter api to send message */
+ mChatPresenter.sendTextMessage(username, chatBoxReceiverEditText.getText().toString(), chatBoxMessageEditText.getText().toString());
+ chatBoxMessageEditText.getText().clear();
+ } else {
+ Toast.makeText(getContext(), R.string.empty_message_not_allowed, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void imageButtonOnClickListener(View view) {
+ Intent photoPickerIntent = new Intent(Intent.ACTION_PICK);
+ photoPickerIntent.setType("image/*");
+ startActivityForResult(photoPickerIntent, ChatFragment.RESULT_LOAD_IMAGE);
+ }
+
+ public void onBackPressed() {
+ mChatPresenter.saveHistoryMessages();
+ mChatPresenter.requestLogout();
+ }
+
+ public void updateRecyclerView(List messages, ScrollDirection direction) {
+ chatContentAdapter.setMessages(messages);
+
+ /* Then refresh the UiThread */
+ requireActivity().runOnUiThread(() -> {
+ chatContentAdapter.notifyDataSetChanged();
+ switch (direction) {
+ case FINGER_UP:
+ chatContentRecyclerView.scrollToPosition(messages.size() - 1);
+ break;
+ case FINGER_DOWN:
+ chatContentRecyclerView.scrollToPosition(0);
+ break;
+ case KEEP_POSITION:
+ default:
+ /* No need to scroll */
+ }
+ });
+
+ chatContentSwipeRefreshLayout.setRefreshing(false);
+ }
+
+ @Override
+ public void detach() {
+ super.detach();
+ mChatPresenter.detach();
+ mChatPresenter = null;
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/LoginFragment.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/LoginFragment.java
new file mode 100644
index 00000000..3f7855f2
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/LoginFragment.java
@@ -0,0 +1,92 @@
+package com.signalr.androidchatroom.view;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.navigation.fragment.NavHostFragment;
+
+import com.signalr.androidchatroom.R;
+import com.signalr.androidchatroom.activity.MainActivity;
+import com.signalr.androidchatroom.contract.LoginContract;
+import com.signalr.androidchatroom.presenter.LoginPresenter;
+import com.signalr.androidchatroom.util.SimpleCallback;
+
+public class LoginFragment extends BaseFragment implements LoginContract.View {
+ private static final String TAG = "LoginFragment";
+
+ private LoginPresenter mLoginPresenter;
+
+ private Button mLoginButton;
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ ((MainActivity) context).setLoginFragment(this);
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mLoginPresenter = new LoginPresenter(this, (MainActivity) requireActivity());
+ }
+
+ @Override
+ public View onCreateView(
+ LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState
+ ) {
+ /* Inflate the layout for this fragment */
+ View view = inflater.inflate(R.layout.fragment_login, container, false);
+
+ /* Get element references */
+ mLoginButton = view.findViewById(R.id.button_login_LoginFragment);
+
+ return view;
+ }
+
+ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ mLoginButton.setOnClickListener(v -> {
+ mLoginButton.setClickable(false);
+ mLoginButton.setText(R.string.connecting);
+ mLoginPresenter.signIn(new SimpleCallback() {
+ @Override
+ public void onError(String errorMessage) {
+ Log.e(TAG, errorMessage);
+ requireActivity().runOnUiThread(() -> {
+ mLoginButton.setClickable(true);
+ mLoginButton.setText(R.string.login);
+ });
+ }
+ });
+ });
+ }
+
+ @Override
+ public void setLogin(String username) {
+ Bundle bundle = new Bundle();
+ bundle.putString("username", username);
+ NavHostFragment.findNavController(LoginFragment.this)
+ .navigate(R.id.action_LoginFragment_to_ChatFragment, bundle);
+ }
+
+ @Override
+ public void detach() {
+ super.detach();
+ mLoginPresenter.detach();
+ mLoginPresenter = null;
+ }
+}
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/ScrollDirection.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/ScrollDirection.java
new file mode 100644
index 00000000..dd73e2e1
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/ScrollDirection.java
@@ -0,0 +1,5 @@
+package com.signalr.androidchatroom.view;
+
+public enum ScrollDirection {
+ FINGER_UP, FINGER_DOWN, KEEP_POSITION;
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/ChatContentAdapter.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/ChatContentAdapter.java
new file mode 100644
index 00000000..43a71be7
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/ChatContentAdapter.java
@@ -0,0 +1,211 @@
+package com.signalr.androidchatroom.view.chatrecyclerview;
+
+import android.graphics.Bitmap;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.signalr.androidchatroom.R;
+import com.signalr.androidchatroom.model.entity.Message;
+import com.signalr.androidchatroom.model.entity.MessageTypeConstant;
+import com.signalr.androidchatroom.presenter.ChatPresenter;
+import com.signalr.androidchatroom.view.ChatFragment;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+public class ChatContentAdapter extends RecyclerView.Adapter {
+
+ private static final String TAG = "ChatContentAdapter";
+
+ private static final int READ_IMAGE_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.READ | MessageTypeConstant.IMAGE | MessageTypeConstant.PRIVATE;
+ private static final int READ_TEXT_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.READ | MessageTypeConstant.TEXT | MessageTypeConstant.PRIVATE;
+ private static final int READ_TEXT_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.READ | MessageTypeConstant.TEXT | MessageTypeConstant.BROADCAST;
+ private static final int READ_IMAGE_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.READ | MessageTypeConstant.TEXT | MessageTypeConstant.IMAGE;
+ private static final int RECEIVED_IMAGE_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.RECEIVED | MessageTypeConstant.IMAGE | MessageTypeConstant.BROADCAST;
+ private static final int RECEIVED_IMAGE_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.RECEIVED | MessageTypeConstant.IMAGE | MessageTypeConstant.PRIVATE;
+ private static final int RECEIVED_TEXT_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.RECEIVED | MessageTypeConstant.TEXT | MessageTypeConstant.BROADCAST;
+ private static final int RECEIVED_TEXT_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.RECEIVED | MessageTypeConstant.TEXT | MessageTypeConstant.PRIVATE;
+ private static final int SYSTEM_MESSAGE_VIEW = MessageTypeConstant.RECEIVED | MessageTypeConstant.TEXT | MessageTypeConstant.SYSTEM;
+ private static final int SENDING_IMAGE_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.SENDING | MessageTypeConstant.IMAGE | MessageTypeConstant.BROADCAST;
+ private static final int SENDING_IMAGE_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.SENDING | MessageTypeConstant.IMAGE | MessageTypeConstant.PRIVATE;
+ private static final int SENDING_TEXT_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.SENDING | MessageTypeConstant.TEXT | MessageTypeConstant.BROADCAST;
+ private static final int SENDING_TEXT_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.SENDING | MessageTypeConstant.TEXT | MessageTypeConstant.PRIVATE;
+ private static final int SENT_IMAGE_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.SENT | MessageTypeConstant.IMAGE | MessageTypeConstant.BROADCAST;
+ private static final int SENT_IMAGE_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.SENT | MessageTypeConstant.IMAGE | MessageTypeConstant.PRIVATE;
+ private static final int SENT_TEXT_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.SENT | MessageTypeConstant.TEXT | MessageTypeConstant.BROADCAST;
+ private static final int SENT_TEXT_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.SENT | MessageTypeConstant.TEXT | MessageTypeConstant.PRIVATE;
+ private static final int TIMEOUT_IMAGE_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.TIMEOUT | MessageTypeConstant.IMAGE | MessageTypeConstant.PRIVATE;
+ private static final int TIMEOUT_TEXT_PRIVATE_MESSAGE_VIEW = MessageTypeConstant.TIMEOUT | MessageTypeConstant.TEXT | MessageTypeConstant.PRIVATE;
+ private static final int TIMEOUT_TEXT_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.TIMEOUT | MessageTypeConstant.TEXT | MessageTypeConstant.BROADCAST;
+ private static final int TIMEOUT_IMAGE_BROADCAST_MESSAGE_VIEW = MessageTypeConstant.TIMEOUT | MessageTypeConstant.TEXT | MessageTypeConstant.IMAGE;
+ private final ChatFragment mChatFragment;
+ private final ChatPresenter mChatPresenter;
+ private final SimpleDateFormat sdf = new SimpleDateFormat("MM/dd hh:mm:ss", Locale.US);
+ private List messages;
+
+ public ChatContentAdapter(List messages, ChatFragment chatFragment, ChatPresenter chatPresenter) {
+ this.messages = messages;
+ mChatFragment = chatFragment;
+ mChatPresenter = chatPresenter;
+ }
+
+ public void setMessages(List messages) {
+ this.messages = messages;
+ }
+
+ @NonNull
+ @Override
+ public ChatContentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View view;
+
+ switch (viewType) {
+ case SENDING_TEXT_BROADCAST_MESSAGE_VIEW:
+ case SENT_TEXT_BROADCAST_MESSAGE_VIEW:
+ case READ_TEXT_BROADCAST_MESSAGE_VIEW:
+ case TIMEOUT_TEXT_BROADCAST_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_self_text_broadcast_message, parent, false);
+ break;
+ case SENDING_TEXT_PRIVATE_MESSAGE_VIEW:
+ case SENT_TEXT_PRIVATE_MESSAGE_VIEW:
+ case READ_TEXT_PRIVATE_MESSAGE_VIEW:
+ case TIMEOUT_TEXT_PRIVATE_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_self_text_private_message, parent, false);
+ break;
+ case SENDING_IMAGE_BROADCAST_MESSAGE_VIEW:
+ case SENT_IMAGE_BROADCAST_MESSAGE_VIEW:
+ case READ_IMAGE_BROADCAST_MESSAGE_VIEW:
+ case TIMEOUT_IMAGE_BROADCAST_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_self_image_broadcast_message, parent, false);
+ break;
+ case SENDING_IMAGE_PRIVATE_MESSAGE_VIEW:
+ case SENT_IMAGE_PRIVATE_MESSAGE_VIEW:
+ case READ_IMAGE_PRIVATE_MESSAGE_VIEW:
+ case TIMEOUT_IMAGE_PRIVATE_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_self_image_private_message, parent, false);
+ break;
+ case RECEIVED_TEXT_BROADCAST_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_received_text_broadcast_message, parent, false);
+ break;
+ case RECEIVED_TEXT_PRIVATE_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_received_text_private_message, parent, false);
+ break;
+ case RECEIVED_IMAGE_BROADCAST_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_received_image_broadcast_message, parent, false);
+ break;
+ case RECEIVED_IMAGE_PRIVATE_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_received_image_private_message, parent, false);
+ break;
+ case SYSTEM_MESSAGE_VIEW:
+ view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_system_message, parent, false);
+ break;
+ default:
+ Log.e(TAG, "Unresolvable Message Type");
+ view = null;
+ }
+
+ return new ChatContentViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ChatContentViewHolder viewHolder, int position) {
+ Message message = messages.get(position);
+
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK) == MessageTypeConstant.SYSTEM) {
+ /* Set system message content text */
+ viewHolder.systemMessageContent.setText(message.getPayload());
+ /* System message only contains a string payload; Directly return */
+ return;
+ }
+
+ /* General chat message components */
+ viewHolder.messageSender.setText(message.getSender());
+ viewHolder.messageTime.setText(sdf.format(new Date(message.getTime())));
+
+ /* Different types of message content */
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_CONTENT_MASK) == MessageTypeConstant.TEXT) {
+ /* Normal text content */
+ viewHolder.messageTextContent.setText(message.getPayload());
+ } else if (((message.getMessageType() & MessageTypeConstant.MESSAGE_CONTENT_MASK) == MessageTypeConstant.IMAGE) && message.getBmp() != null) {
+ /* Loaded image content */
+ Bitmap bmp = message.getBmp();
+ int[] resized = resizeImage(bmp.getWidth(), bmp.getHeight(), 400);
+ viewHolder.messageImageContent.setImageBitmap(Bitmap.createScaledBitmap(bmp, resized[0], resized[1], false));
+ } else if (((message.getMessageType() & MessageTypeConstant.MESSAGE_CONTENT_MASK) == MessageTypeConstant.IMAGE) && message.getBmp() == null) {
+ viewHolder.messageImageContent.setImageResource(R.drawable.ic_ready_to_pull);
+ /* Image content need to load */
+ if (message.getBmp() == null) {
+ viewHolder.bindImageClick(message, v -> {
+ if (message.getBmp() == null) {
+ Log.d(TAG, "Click on image");
+ viewHolder.messageImageContent.setImageResource(R.drawable.ic_pulling);
+ mChatPresenter.pullImageContent(message.getMessageId());
+ }
+ });
+ }
+ }
+
+ /* Different message status */
+ switch (message.getMessageType() & MessageTypeConstant.MESSAGE_STATUS_MASK) {
+ case MessageTypeConstant.SENDING:
+ viewHolder.statusTextView.setText(R.string.message_sending);
+ viewHolder.statusTextView.setOnClickListener(null);
+ break;
+ case MessageTypeConstant.TIMEOUT:
+ viewHolder.statusTextView.setText(R.string.message_resend);
+ viewHolder.bindStatusClick(message, v -> {
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_STATUS_MASK) == MessageTypeConstant.TIMEOUT) {
+ viewHolder.statusTextView.setText(R.string.message_sending);
+ mChatPresenter.resendMessage(message.getMessageId());
+ }
+ });
+ break;
+ case MessageTypeConstant.SENT:
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK) == MessageTypeConstant.PRIVATE &&
+ position == messages.size() - 1) {
+ /* Only last private message need to show SENT status */
+ viewHolder.statusTextView.setText(R.string.message_sent);
+ } else {
+ viewHolder.statusTextView.setText(R.string.message_empty);
+ }
+ break;
+ case MessageTypeConstant.READ:
+ if ((message.getMessageType() & MessageTypeConstant.MESSAGE_TYPE_MASK) == MessageTypeConstant.PRIVATE &&
+ position == messages.size() - 1) {
+ viewHolder.statusTextView.setText(R.string.message_read);
+ } else {
+ viewHolder.statusTextView.setText(R.string.message_empty);
+ }
+ break;
+ default:
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return this.messages.size();
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return messages.get(position).getMessageType();
+ }
+
+ private int[] resizeImage(int width, int height, int maxValue) {
+ if (width > height && width > maxValue) {
+ height = height * maxValue / width;
+ width = maxValue;
+ } else if (width < height && height > maxValue) {
+ width = width * maxValue / height;
+ height = maxValue;
+ }
+ return new int[]{width, height};
+ }
+
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/ChatContentViewHolder.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/ChatContentViewHolder.java
new file mode 100644
index 00000000..82495910
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/ChatContentViewHolder.java
@@ -0,0 +1,44 @@
+package com.signalr.androidchatroom.view.chatrecyclerview;
+
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.signalr.androidchatroom.R;
+import com.signalr.androidchatroom.model.entity.Message;
+
+public class ChatContentViewHolder extends RecyclerView.ViewHolder {
+ /* For all non-system message */
+ public final TextView messageSender;
+ public final TextView messageTime;
+ public final TextView statusTextView;
+
+ /* For Text Message */
+ public final TextView messageTextContent;
+
+ /* For Image Message */
+ public final ImageView messageImageContent;
+
+ /* For System Message */
+ public final TextView systemMessageContent;
+
+ ChatContentViewHolder(View view) {
+ super(view);
+ this.messageSender = view.findViewById(R.id.textview_message_sender);
+ this.messageTime = view.findViewById(R.id.textview_message_time);
+ this.statusTextView = view.findViewById(R.id.textview_message_status);
+ this.messageTextContent = view.findViewById(R.id.textview_message_content);
+ this.messageImageContent = view.findViewById(R.id.imageview_message_content);
+ this.systemMessageContent = view.findViewById(R.id.textview_enter_leave_content);
+ }
+
+ public void bindImageClick(final Message message, final RecyclerViewItemClickListener listener) {
+ messageImageContent.setOnClickListener(v -> listener.onClickItem(message));
+ }
+
+ public void bindStatusClick(final Message message, final RecyclerViewItemClickListener listener) {
+ statusTextView.setOnClickListener(v -> listener.onClickItem(message));
+ }
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/RecyclerViewItemClickListener.java b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/RecyclerViewItemClickListener.java
new file mode 100644
index 00000000..268ff283
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/java/com/signalr/androidchatroom/view/chatrecyclerview/RecyclerViewItemClickListener.java
@@ -0,0 +1,7 @@
+package com.signalr.androidchatroom.view.chatrecyclerview;
+
+import com.signalr.androidchatroom.model.entity.Message;
+
+public interface RecyclerViewItemClickListener {
+ void onClickItem(Message message);
+}
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 00000000..f9ee5feb
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/enter_leave_message_bubble.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/enter_leave_message_bubble.xml
new file mode 100644
index 00000000..e261f82d
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/enter_leave_message_bubble.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_app.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_app.xml
new file mode 100644
index 00000000..22f34853
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_app.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_launcher_background.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..ca3826a4
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_launcher_foreground.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..f9ee5feb
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_pulling.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_pulling.xml
new file mode 100644
index 00000000..408387a5
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_pulling.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_ready_to_pull.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_ready_to_pull.xml
new file mode 100644
index 00000000..cadc070f
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/ic_ready_to_pull.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/received_broadcast_message_bubble.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/received_broadcast_message_bubble.xml
new file mode 100644
index 00000000..4aa93c0b
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/received_broadcast_message_bubble.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/received_private_message_bubble.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/received_private_message_bubble.xml
new file mode 100644
index 00000000..e261f82d
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/received_private_message_bubble.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/self_message_bubble.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/self_message_bubble.xml
new file mode 100644
index 00000000..42a9e217
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/self_message_bubble.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/sent_private_message_bubble.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/sent_private_message_bubble.xml
new file mode 100644
index 00000000..e261f82d
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/drawable/sent_private_message_bubble.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/activity_main.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 00000000..f042781f
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/content_main.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/content_main.xml
new file mode 100644
index 00000000..c4e7db67
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/content_main.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/fragment_chat.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/fragment_chat.xml
new file mode 100644
index 00000000..7e9c2b12
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/fragment_chat.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/fragment_login.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/fragment_login.xml
new file mode 100644
index 00000000..6e3bd48e
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/fragment_login.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_image_broadcast_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_image_broadcast_message.xml
new file mode 100644
index 00000000..999d1981
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_image_broadcast_message.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_image_private_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_image_private_message.xml
new file mode 100644
index 00000000..41084e75
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_image_private_message.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_text_broadcast_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_text_broadcast_message.xml
new file mode 100644
index 00000000..d3db2da5
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_text_broadcast_message.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_text_private_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_text_private_message.xml
new file mode 100644
index 00000000..9bdfcd90
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_received_text_private_message.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_image_broadcast_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_image_broadcast_message.xml
new file mode 100644
index 00000000..a326c1ba
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_image_broadcast_message.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_image_private_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_image_private_message.xml
new file mode 100644
index 00000000..62d23259
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_image_private_message.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_text_broadcast_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_text_broadcast_message.xml
new file mode 100644
index 00000000..d875ba45
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_text_broadcast_message.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_text_private_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_text_private_message.xml
new file mode 100644
index 00000000..5f6daaa2
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_self_text_private_message.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_system_message.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_system_message.xml
new file mode 100644
index 00000000..6ae3be0e
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/layout/item_system_message.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..7353dbd1
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..7353dbd1
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-hdpi/ic_launcher.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..02e39483
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..b684cdde
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-mdpi/ic_launcher.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..c15175a2
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..8441f0b7
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..e3a5f9f6
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..26927423
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..ea0662e1
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..9f55ff5f
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..5c32d375
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..69750527
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/navigation/nav_graph.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/navigation/nav_graph.xml
new file mode 100644
index 00000000..5203eac4
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/navigation/nav_graph.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/raw/auth_config_single_account.json b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/raw/auth_config_single_account.json
new file mode 100644
index 00000000..9c1d02f4
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/raw/auth_config_single_account.json
@@ -0,0 +1,15 @@
+{
+ "client_id": "YOUR_CLIENT_ID_HERE",
+ "authorization_user_agent": "DEFAULT",
+ "redirect_uri": "msauth://com.signalr.androidchatroom/YOUR_SIGNATURE_HASH_HERE",
+ "account_mode": "SINGLE",
+ "authorities": [
+ {
+ "type": "AAD",
+ "audience": {
+ "type": "AzureADandPersonalMicrosoftAccount"
+ },
+ "default": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/colors.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..4faecfa8
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #6200EE
+ #3700B3
+ #03DAC5
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/dimens.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..125df871
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/dimens.xml
@@ -0,0 +1,3 @@
+
+ 16dp
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/ic_launcher_background.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 00000000..c5d5899f
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/strings_general.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/strings_general.xml
new file mode 100644
index 00000000..3c3d3660
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/strings_general.xml
@@ -0,0 +1,38 @@
+
+ http://10.0.2.2:5000/chat
+
+ SignalR Android Chat Room
+ Settings
+
+ Login Page
+ ChatRoom
+ Let\'s Chat!
+ Connecting
+ User
+
+ Receiver. Blank for broadcast.
+ Enter message here.
+ Send
+ Choose Image
+ Oct.1st 2020
+
+ Sending
+ Resend
+ Sent
+ Read
+
+
+ Message Notification Channel
+ Message Notification Channel Description
+
+ Session expired!
+ Alert
+ Re-Login
+
+ Empty message is not allowed.
+
+ Image picking failed.
+ You haven\'t picked Image.
+
+ saved_messages
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/strings_secrets.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/strings_secrets.xml
new file mode 100644
index 00000000..20f0d011
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/strings_secrets.xml
@@ -0,0 +1,5 @@
+
+
+ YOUR_CONNECTION_STRING_HERE
+ AndroidChatRoomNotificationHub
+
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/styles.xml b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/styles.xml
new file mode 100644
index 00000000..21d9cedd
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/app/src/main/res/values/styles.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/build.gradle b/samples/MobileChatRoom/AndroidChatRoomClient/build.gradle
new file mode 100644
index 00000000..976b2323
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/build.gradle
@@ -0,0 +1,27 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ repositories {
+ google()
+ jcenter()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:4.1.1'
+ classpath 'com.google.gms:google-services:4.3.4'
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ maven {
+ url 'https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1'
+ }
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/gradle.properties b/samples/MobileChatRoom/AndroidChatRoomClient/gradle.properties
new file mode 100644
index 00000000..c52ac9b7
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/gradle.properties
@@ -0,0 +1,19 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
\ No newline at end of file
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/gradle/wrapper/gradle-wrapper.jar b/samples/MobileChatRoom/AndroidChatRoomClient/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..f6b961fd
Binary files /dev/null and b/samples/MobileChatRoom/AndroidChatRoomClient/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/gradle/wrapper/gradle-wrapper.properties b/samples/MobileChatRoom/AndroidChatRoomClient/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..26400040
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Oct 19 15:13:36 CST 2020
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/gradlew b/samples/MobileChatRoom/AndroidChatRoomClient/gradlew
new file mode 100644
index 00000000..cccdd3d5
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/gradlew.bat b/samples/MobileChatRoom/AndroidChatRoomClient/gradlew.bat
new file mode 100644
index 00000000..f9553162
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/samples/MobileChatRoom/AndroidChatRoomClient/settings.gradle b/samples/MobileChatRoom/AndroidChatRoomClient/settings.gradle
new file mode 100644
index 00000000..57451fb8
--- /dev/null
+++ b/samples/MobileChatRoom/AndroidChatRoomClient/settings.gradle
@@ -0,0 +1,2 @@
+include ':app'
+rootProject.name = "AndroidChatRoomClient"
\ No newline at end of file
diff --git a/samples/MobileChatRoom/README.md b/samples/MobileChatRoom/README.md
new file mode 100644
index 00000000..95a07c1b
--- /dev/null
+++ b/samples/MobileChatRoom/README.md
@@ -0,0 +1,392 @@
+# Build SignalR-based Android Chatting App
+
+This tutorial shows you how to build and modify a SignalR-based Android Chatting App. You'll learn how to:
+
+> **✓** Create and Configure Your App in Azure Active Directory.
+>
+> **✓** Configure Your Local Android Mobile ChatRoom App.
+>
+> **✓** Integrate the chat room app with [ReliableChatRoom Server](../ReliableChatRoom/.)
+>
+> **✓** Chat With Mobile Chat Room App.
+>
+
+## Prerequisites
+* Install [.NET Core 3.0 SDK](https://dotnet.microsoft.com/download/dotnet-core/3.0) (Version >= 3.0.100)
+* Install [Visual Studio 2019](https://visualstudio.microsoft.com/vs/) (Version >= 16.3)
+* Install [Android Studio](https://developer.android.com/studio) (We use ver. 4.1)
+
+
+## Download Source Code from Repository
+
+1. Download or clone the Android Studio project
+
+ ```cmd
+ git clone https://github.com/$USERNAME/AzureSignalR-samples.git
+ ```
+
+2. Open the directory as project in Android Studio
+
+ Open Android Studio -> Open an Existing Project
+
+ ![open-project](./assets/0-2-1-open-project.png)
+
+ Select the project directory -> OK
+
+ ![open-project](./assets/0-2-2-open-project.png)
+
+3. Let the gradle run for dependency resolving while we move on to the next section.
+
+## Configure Your App in Azure Active Directory
+
+1. Enter `Azure Active Directory` in `Azure Portal`
+
+ Enter [Azure Portal](https://portal.azure.com/) and click on `Azure Active Directory`
+
+ ![azure-portal](./assets/1-1-1-azure-portal.png)
+
+2. Register a new app
+
+ 1. Click on `App registrations` and then `New registration`
+
+ ![aad](./assets/1-2-1-aad.png)
+
+ 2. Enter app name and chose the 3rd option in `Supported account types`
+
+ ![app-registration](./assets/1-2-2-app-registration.png)
+
+ 3. Click `Register` button at the bottom
+
+3. Copy Client ID in the text editor for later use
+
+ You can find Client ID in the `Overview` tab of your newly registered app.
+
+ ![client-id](./assets/1-3-1-client-id.png)
+
+4. Add an authentication for Android clients
+
+ 1. In the `Authentication` tab of your newly registered app, click `Add a platform`.
+
+ ![aad-authentication](./assets/1-4-1-aad-authentication.png)
+
+ 2. Enter your Android app's package name (in our case should be `com.signalr.androidchatroom`) and a self-generated signature hash string with the provided command.
+
+ ![config-authentication](./assets/1-4-2-config-authentication.png)
+
+ 3. Copy your generated signature hash string in text editor
+
+ 4. Click `Configure` button
+
+ 5. Click `Save` button at top-left
+
+5. Wire your AAD Android authentication information into local Android App
+
+ 1. Click `View` button in your newly added Android authentication method.
+
+ ![view-json](./assets/1-5-1-view-json.png)
+
+ 2. Copy the JSON by clicking the button and then paste it into `AzureSignalR-samples\samples\MobileChatRoom\AndroidChatRoomClient\app\src\main\res\raw\auth_config_single_account.json`
+
+ ![copy-json](./assets/1-5-2-copy-json.png)
+
+ There should already be a json file there. You can either replace the fields with ones you copied in your text editor earlier or just overwrite the whole file.
+
+ ![existed-json](./assets/1-5-3-existed-json.png)
+
+
+## Configure Your Local Android Mobile ChatRoom App and Azure App Service
+
+1. Download and place `google-services.json`
+
+ 1. In [Firebase Console](https://console.firebase.google.com/) -> Click your project
+
+ 2. In `Settings` -> `Project Settings` -> Download `google-services.json` -> Copy it to `AzureSignalR-samples\samples\MobileChatRoom\AndroidChatRoomClient\app\google-services.json`
+
+2. Paste your Azure Notification Hub connection string
+
+ 1. We assume you've already created an `Azure Notification Hub` when configuring the app server. If not, please read [*Build A SignalR-based Reliable Mobile Chat Room Server*](../ReliableChatRoom/README.md)
+
+ 2. Copy and paste the `Connection String` and `Hub Name` in `AzureSignalR-samples\samples\MobileChatRoom\AndroidChatRoomClient\app\src\main\res\values\strings_secrets.xml`
+
+ ![not-hub-conn-str-copy](./assets/2-2-1-not-hub-conn-str-copy.png)
+ ![not-hub-conn-str-paste](./assets/2-2-2-not-hub-conn-str-paste.png)
+
+ ![not-hub-name-copy](./assets/2-2-3-not-hub-name-copy.png)
+ ![not-hub-name-paste](./assets/2-2-4-not-hub-name-paste.png)
+
+3. Paste your Azure App Service Url
+
+ 1. We assume you've already created an `Azure App Service` when configuring the app server. If not, please read [*Build A SignalR-based Reliable Mobile Chat Room Server*](../ReliableChatRoom/README.md)
+
+ 2. Copy and paste the `URL` to `AzureSignalR-samples\samples\MobileChatRoom\AndroidChatRoomClient\app\src\main\java\com\signalr\androidchatroom\service\SignalRService.java`
+
+ ![app-service-copy](./assets/2-3-1-app-service-copy.png)
+ ![app-service-paste](./assets/2-3-2-app-service-paste.png)
+
+4. Allow client requests in `Azure App Service`
+
+ 1. We assume you've already created an `Azure App Service` when configuring the app server. If not, please read [*Build A SignalR-based Reliable Mobile Chat Room Server*](../ReliableChatRoom/README.md)
+
+ 2. Generate a client secret in AAD
+
+ `Azure Active Directory` -> `App registrations` -> `YOUR_APP_NAME` -> `Certificates & secrets` -> `New client secret`
+
+ ![new-client-secret](./assets/2-4-1-new-client-secret.png)
+
+ Enter a `Description` and specify a `valid date` and then create the secret.
+
+ Copy the `Value` field in text editor (This is the only chance to access the complete client secret value since old client secret values will **NOT** display, like the below image.)
+
+ ![old-client-secret](./assets/2-4-2-old-client-secret.png)
+
+ 3. Add client secret to `Azure App Service`
+
+ In `YOUR_AZURE_APP_SERVICE` -> `Authentication / Authorization`, turn on `App Service Authentication` switch;
+
+ Select `Action to take when request is not authenticated` to `Log in with Azure Active Directory`;
+
+ Click `Azure Active Directory` in `Authentication Providers`.
+
+ ![auth](./assets/2-4-3-auth.png)
+
+ In the detailed configuration dialog, switch `Management mode` to `Advanced` and click `Show Secret`;
+
+ Paste your AAD Client ID in the `Client ID` field;
+
+ Paste your client secret value to `Client Secret (Optional)` field;
+
+ For `Issuer Url` field, if you would like anyone with a Microsoft/Outlook/Live/Xbox account to sign into your app, use `https://login.microsoftonline.com/common/v2.0`.
+
+ Otherwise, if only accounts under your AAD are allowed, use your own AAD endpoint URL which should align with the format of `https://login.microsoftonline.com/YOUR_AAD_TENANT_OR_DIRECTORY_ID/v2.0`.
+
+ ![auth-advanced](./assets/2-4-4-auth-advanced.png)
+
+ Don't forget to click `OK` and then `Save` the `Authentication / Authorization`.
+
+
+
+
+
+## Chat With Mobile Chat Room App
+
+1. Build and run your app server
+
+ You have two options:
+
+ 1. Run app server locally
+
+ In `AzureSignalR-samples\samples\MobileChatRoom\AndroidChatRoomClient\app\src\main\java\com\signalr\androidchatroom\service\SignalRService.java`
+
+ Set `serverUrl` field to `localDebugUrl`.
+
+ In your chat room server project directory
+ ```cmd
+ cd AzureSignalR-samples\samples\ReliableChatRoom\ReliableChatRoom
+ dotnet run
+ ```
+
+ 2. Publish and run app server on `Azure App Service`
+
+ In `AzureSignalR-samples\samples\MobileChatRoom\AndroidChatRoomClient\app\src\main\java\com\signalr\androidchatroom\service\SignalRService.java`
+
+ Set `serverUrl` field to `azureAppServiceUrl`.
+
+ See [reference](../ReliableChatRoom/README.md) of *Build A SignalR-based Reliable Mobile Chat Room Server*.
+
+
+2. Launch Android Chat Room Clients
+
+ 1. Create two AVD in the Android Emulator
+
+ ![avd](./assets/3-2-1-avd.png)
+
+ 2. Run the app on multiple devices
+
+ ![run-app](./assets/3-2-2-run-app.png)
+
+ 3. Hit `LET'S CHAT!` button
+
+ ![lets-chat](./assets/3-2-3-lets-chat.png)
+
+ 4. Log in with Microsoft/Outlook/Live/Xbox account
+
+ ![signin](./assets/3-2-4-signin.png)
+
+ 5. Start chatting
+ ![start-chatting](./assets/3-2-5-start-chatting.png)
+
+## How to Send Different Messages
+
+1. Broadcast text message
+
+ 1. Leave the receiver field blank
+
+ 2. Type your message in message field
+
+ 3. Hit the `SEND` button
+
+ ![broadcast-text](./assets/4-1-1-broadcast-text.png)
+
+2. Private text message
+
+ 1. Type your receiver name in receiver field
+
+ 2. Type your message in message field
+
+ 3. Hit the `SEND` button
+
+ ![private-text](./assets/4-2-1-private-text.png)
+
+3. Broadcast image message
+
+ 1. Hit the `CHOOSE IMAGE` button and select your image
+
+ 2. The image you chose will be sent
+
+ ![broadcast-image](./assets/4-3-1-broadcast-image.png)
+
+4. Private image message
+
+ 1. Type your receiver name in receiver field
+
+ 2. Hit the `CHOOSE IMAGE` button and select your image
+
+ 3. The image you chose will be sent
+
+ ![private-image](./assets/4-4-1-private-image.png)
+
+## Message Receiver Side
+
+1. History Message Pulling
+
+ Manually swipe down at the very top of message list and then release will trigger a history message pulling request.
+
+ Here's an example.
+
+ ![history-message](./assets/5-1-1-history-message.png)
+
+2. Image Message Loading
+
+ If you are a receiver of any image message, you will see the message first as a white square inside the bubble. For example:
+
+ ![white-square](./assets/5-2-1-white-square.png)
+
+ To load the content of the image, just click the white square. There will be a smaller red square inside it indicating the loading of image. For example:
+
+ ![red-square](./assets/5-2-2-red-square.png)
+
+ After loading is finished, the whole content of image will show.
+
+ ![image-loaded](./assets/5-2-3-image-loaded.png)
+
+## Message Sender Side
+
+1. Resend a message
+
+ If your network status is unstable, your sent message might not be received by the server or your receiver client. To resend, just wait for the `Sending` status shown beside the bubble to turn to `Resend`, and the click the `Resend`.
+
+ ![resend-broadcast](./assets/6-1-1-resend-broadcast.png)
+
+2. Status of private messages
+
+ If you have sent a private message, you'll be able to see the status of your message. Currently, the message status includes: `Sending`, `Sent`, and `Read`.
+
+ `Sending` - Your message is making its way to the chat room app server.
+
+ `Sent` - Your message has arrived at the chat room app server, but hasn't been read by or sent to your receiver client,
+
+ `Read` - Your message has been read by your receiver client.
+
+ For example:
+
+ ![sending-private](./assets/6-2-1-sending-private.png)
+ ![sent-private](./assets/6-2-2-sent-private.png)
+ ![read-private](./assets/6-2-3-read-private.png)
+
+## Client-side Interface Specification
+
+Overview:
+
+![overview-interface](./assets/7-1-1-overview-interface.png)
+
+Java view:
+
+```java
+/**
+ * Receives a system message from SignalR server.
+ *
+ * @param messageId A server-generated unique string of message id.
+ * @param payload Body content of the system message (can only be text).
+ * @param sendTime A long int representing the time when the system was sent.
+ */
+void receiveSystemMessage(String messageId, String payload, long sendTime);
+
+/**
+ * Received a broadcast message from SignalR server.
+ *
+ * @param messageId A server-generated unique string of message id.
+ * @param sender A string of sender.
+ * @param receiver A string of receiver (can only be "BCAST").
+ * @param payload Body content of the broadcast message (can be either text
+ * or binary image contents encoded in base64).
+ * @param isImage A boolean indicating whether the broadcast is an image message.
+ * @param sendTime A long int representing the time when the system was sent.
+ * @param ackId A string of ack id that client will later send back to server as Ack response.
+ */
+void receiveBroadcastMessage(String messageId, String sender, String receiver, String payload, boolean isImage, long sendTime, String ackId);
+
+/**
+ * Received a private message from SignalR server.
+ *
+ * @param messageId A server-generated unique string of message id.
+ * @param sender A string of sender.
+ * @param receiver A string of receiver.
+ * @param payload Body content of the broadcast message (can be either text
+ * or binary image contents encoded in base64).
+ * @param isImage A boolean indicating whether the broadcast is an image message.
+ * @param sendTime A long int representing the time when the system was sent.
+ * @param ackId A string of ack id that client will later send back to server as Ack response.
+ */
+void receivePrivateMessage(String messageId, String sender, String receiver, String payload, boolean isImage, long sendTime, String ackId);
+
+/**
+ * Receives history messages from SignalR server.
+ * Usually called by server right after a client pullHistoryMessage request.
+ *
+ * @param serializedString A json string of list of history messages.
+ */
+void receiveHistoryMessages(String serializedString);
+
+/**
+ * Receives image content from SignalR server.
+ *
+ * @param messageId A string representing a message id.
+ * @param payload Binary image contents encoded in base64.
+ */
+void receiveImageContent(String messageId, String payload);
+
+/**
+ * Receives a client read response from SignalR server.
+ * The client read response is sent from another client to server.
+ *
+ * @param messageId A string representing a message id.
+ * @param username A string representing the username of sender of the read response.
+ */
+void clientRead(String messageId, String username);
+
+/**
+ * Receives a server ack from SignalR server.
+ * The server ack is a server response of successfully receiving a client message.
+ *
+ * @param messageId A string representing a message id.
+ * @param receivedTimeInLong A long int representing the received time in milliseconds.
+ */
+void serverAck(String messageId, long receivedTimeInLong);
+
+/**
+ * Expires a client session from SignalR server.
+ *
+ * @param isForced A boolean indicating whether the expire is forced.
+ */
+void expireSession(boolean isForced);
+```
\ No newline at end of file
diff --git a/samples/MobileChatRoom/assets/0-2-1-open-project.png b/samples/MobileChatRoom/assets/0-2-1-open-project.png
new file mode 100644
index 00000000..80131efd
Binary files /dev/null and b/samples/MobileChatRoom/assets/0-2-1-open-project.png differ
diff --git a/samples/MobileChatRoom/assets/0-2-2-open-project.png b/samples/MobileChatRoom/assets/0-2-2-open-project.png
new file mode 100644
index 00000000..b7773a53
Binary files /dev/null and b/samples/MobileChatRoom/assets/0-2-2-open-project.png differ
diff --git a/samples/MobileChatRoom/assets/1-1-1-azure-portal.png b/samples/MobileChatRoom/assets/1-1-1-azure-portal.png
new file mode 100644
index 00000000..2965de3a
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-1-1-azure-portal.png differ
diff --git a/samples/MobileChatRoom/assets/1-2-1-aad.png b/samples/MobileChatRoom/assets/1-2-1-aad.png
new file mode 100644
index 00000000..660abb87
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-2-1-aad.png differ
diff --git a/samples/MobileChatRoom/assets/1-2-2-app-registration.png b/samples/MobileChatRoom/assets/1-2-2-app-registration.png
new file mode 100644
index 00000000..ead2f2b4
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-2-2-app-registration.png differ
diff --git a/samples/MobileChatRoom/assets/1-3-1-client-id.png b/samples/MobileChatRoom/assets/1-3-1-client-id.png
new file mode 100644
index 00000000..575621ae
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-3-1-client-id.png differ
diff --git a/samples/MobileChatRoom/assets/1-4-1-aad-authentication.png b/samples/MobileChatRoom/assets/1-4-1-aad-authentication.png
new file mode 100644
index 00000000..9886f4a3
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-4-1-aad-authentication.png differ
diff --git a/samples/MobileChatRoom/assets/1-4-2-config-authentication.png b/samples/MobileChatRoom/assets/1-4-2-config-authentication.png
new file mode 100644
index 00000000..d9251f6f
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-4-2-config-authentication.png differ
diff --git a/samples/MobileChatRoom/assets/1-5-1-view-json.png b/samples/MobileChatRoom/assets/1-5-1-view-json.png
new file mode 100644
index 00000000..b8aa0747
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-5-1-view-json.png differ
diff --git a/samples/MobileChatRoom/assets/1-5-2-copy-json.png b/samples/MobileChatRoom/assets/1-5-2-copy-json.png
new file mode 100644
index 00000000..914d4e43
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-5-2-copy-json.png differ
diff --git a/samples/MobileChatRoom/assets/1-5-3-existed-json.png b/samples/MobileChatRoom/assets/1-5-3-existed-json.png
new file mode 100644
index 00000000..0537519e
Binary files /dev/null and b/samples/MobileChatRoom/assets/1-5-3-existed-json.png differ
diff --git a/samples/MobileChatRoom/assets/2-2-1-not-hub-conn-str-copy.png b/samples/MobileChatRoom/assets/2-2-1-not-hub-conn-str-copy.png
new file mode 100644
index 00000000..6fd90eab
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-2-1-not-hub-conn-str-copy.png differ
diff --git a/samples/MobileChatRoom/assets/2-2-2-not-hub-conn-str-paste.png b/samples/MobileChatRoom/assets/2-2-2-not-hub-conn-str-paste.png
new file mode 100644
index 00000000..3c8d8aba
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-2-2-not-hub-conn-str-paste.png differ
diff --git a/samples/MobileChatRoom/assets/2-2-3-not-hub-name-copy.png b/samples/MobileChatRoom/assets/2-2-3-not-hub-name-copy.png
new file mode 100644
index 00000000..6a54ef6a
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-2-3-not-hub-name-copy.png differ
diff --git a/samples/MobileChatRoom/assets/2-2-4-not-hub-name-paste.png b/samples/MobileChatRoom/assets/2-2-4-not-hub-name-paste.png
new file mode 100644
index 00000000..e5bb12dd
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-2-4-not-hub-name-paste.png differ
diff --git a/samples/MobileChatRoom/assets/2-3-1-app-service-copy.png b/samples/MobileChatRoom/assets/2-3-1-app-service-copy.png
new file mode 100644
index 00000000..1a57aa43
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-3-1-app-service-copy.png differ
diff --git a/samples/MobileChatRoom/assets/2-3-2-app-service-paste.png b/samples/MobileChatRoom/assets/2-3-2-app-service-paste.png
new file mode 100644
index 00000000..0925a108
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-3-2-app-service-paste.png differ
diff --git a/samples/MobileChatRoom/assets/2-4-1-new-client-secret.png b/samples/MobileChatRoom/assets/2-4-1-new-client-secret.png
new file mode 100644
index 00000000..1fed3f5f
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-4-1-new-client-secret.png differ
diff --git a/samples/MobileChatRoom/assets/2-4-2-old-client-secret.png b/samples/MobileChatRoom/assets/2-4-2-old-client-secret.png
new file mode 100644
index 00000000..2eaa8a95
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-4-2-old-client-secret.png differ
diff --git a/samples/MobileChatRoom/assets/2-4-3-auth.png b/samples/MobileChatRoom/assets/2-4-3-auth.png
new file mode 100644
index 00000000..8a2bea6f
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-4-3-auth.png differ
diff --git a/samples/MobileChatRoom/assets/2-4-4-auth-advanced.png b/samples/MobileChatRoom/assets/2-4-4-auth-advanced.png
new file mode 100644
index 00000000..60ab0262
Binary files /dev/null and b/samples/MobileChatRoom/assets/2-4-4-auth-advanced.png differ
diff --git a/samples/MobileChatRoom/assets/3-2-1-avd.png b/samples/MobileChatRoom/assets/3-2-1-avd.png
new file mode 100644
index 00000000..4ef8a5ad
Binary files /dev/null and b/samples/MobileChatRoom/assets/3-2-1-avd.png differ
diff --git a/samples/MobileChatRoom/assets/3-2-2-run-app.png b/samples/MobileChatRoom/assets/3-2-2-run-app.png
new file mode 100644
index 00000000..f001500c
Binary files /dev/null and b/samples/MobileChatRoom/assets/3-2-2-run-app.png differ
diff --git a/samples/MobileChatRoom/assets/3-2-3-lets-chat.png b/samples/MobileChatRoom/assets/3-2-3-lets-chat.png
new file mode 100644
index 00000000..adcfe357
Binary files /dev/null and b/samples/MobileChatRoom/assets/3-2-3-lets-chat.png differ
diff --git a/samples/MobileChatRoom/assets/3-2-4-signin.png b/samples/MobileChatRoom/assets/3-2-4-signin.png
new file mode 100644
index 00000000..f8532955
Binary files /dev/null and b/samples/MobileChatRoom/assets/3-2-4-signin.png differ
diff --git a/samples/MobileChatRoom/assets/3-2-5-start-chatting.png b/samples/MobileChatRoom/assets/3-2-5-start-chatting.png
new file mode 100644
index 00000000..cccfe6e2
Binary files /dev/null and b/samples/MobileChatRoom/assets/3-2-5-start-chatting.png differ
diff --git a/samples/MobileChatRoom/assets/4-1-1-broadcast-text.png b/samples/MobileChatRoom/assets/4-1-1-broadcast-text.png
new file mode 100644
index 00000000..51c3ff01
Binary files /dev/null and b/samples/MobileChatRoom/assets/4-1-1-broadcast-text.png differ
diff --git a/samples/MobileChatRoom/assets/4-2-1-private-text.png b/samples/MobileChatRoom/assets/4-2-1-private-text.png
new file mode 100644
index 00000000..eeaeaebd
Binary files /dev/null and b/samples/MobileChatRoom/assets/4-2-1-private-text.png differ
diff --git a/samples/MobileChatRoom/assets/4-3-1-broadcast-image.png b/samples/MobileChatRoom/assets/4-3-1-broadcast-image.png
new file mode 100644
index 00000000..9f35e475
Binary files /dev/null and b/samples/MobileChatRoom/assets/4-3-1-broadcast-image.png differ
diff --git a/samples/MobileChatRoom/assets/4-4-1-private-image.png b/samples/MobileChatRoom/assets/4-4-1-private-image.png
new file mode 100644
index 00000000..3dca2cd9
Binary files /dev/null and b/samples/MobileChatRoom/assets/4-4-1-private-image.png differ
diff --git a/samples/MobileChatRoom/assets/4-different-usernames.png b/samples/MobileChatRoom/assets/4-different-usernames.png
new file mode 100644
index 00000000..e4e0105a
Binary files /dev/null and b/samples/MobileChatRoom/assets/4-different-usernames.png differ
diff --git a/samples/MobileChatRoom/assets/4-start-chatting.png b/samples/MobileChatRoom/assets/4-start-chatting.png
new file mode 100644
index 00000000..b66539eb
Binary files /dev/null and b/samples/MobileChatRoom/assets/4-start-chatting.png differ
diff --git a/samples/MobileChatRoom/assets/5-1-1-history-message.png b/samples/MobileChatRoom/assets/5-1-1-history-message.png
new file mode 100644
index 00000000..dfb95ca6
Binary files /dev/null and b/samples/MobileChatRoom/assets/5-1-1-history-message.png differ
diff --git a/samples/MobileChatRoom/assets/5-2-1-white-square.png b/samples/MobileChatRoom/assets/5-2-1-white-square.png
new file mode 100644
index 00000000..8b2bbc64
Binary files /dev/null and b/samples/MobileChatRoom/assets/5-2-1-white-square.png differ
diff --git a/samples/MobileChatRoom/assets/5-2-2-red-square.png b/samples/MobileChatRoom/assets/5-2-2-red-square.png
new file mode 100644
index 00000000..d690205a
Binary files /dev/null and b/samples/MobileChatRoom/assets/5-2-2-red-square.png differ
diff --git a/samples/MobileChatRoom/assets/5-2-3-image-loaded.png b/samples/MobileChatRoom/assets/5-2-3-image-loaded.png
new file mode 100644
index 00000000..c80e8d90
Binary files /dev/null and b/samples/MobileChatRoom/assets/5-2-3-image-loaded.png differ
diff --git a/samples/MobileChatRoom/assets/6-1-1-resend-broadcast.png b/samples/MobileChatRoom/assets/6-1-1-resend-broadcast.png
new file mode 100644
index 00000000..98859a9f
Binary files /dev/null and b/samples/MobileChatRoom/assets/6-1-1-resend-broadcast.png differ
diff --git a/samples/MobileChatRoom/assets/6-2-1-sending-private.png b/samples/MobileChatRoom/assets/6-2-1-sending-private.png
new file mode 100644
index 00000000..30f4d144
Binary files /dev/null and b/samples/MobileChatRoom/assets/6-2-1-sending-private.png differ
diff --git a/samples/MobileChatRoom/assets/6-2-2-sent-private.png b/samples/MobileChatRoom/assets/6-2-2-sent-private.png
new file mode 100644
index 00000000..41b70486
Binary files /dev/null and b/samples/MobileChatRoom/assets/6-2-2-sent-private.png differ
diff --git a/samples/MobileChatRoom/assets/6-2-3-read-private.png b/samples/MobileChatRoom/assets/6-2-3-read-private.png
new file mode 100644
index 00000000..d8611005
Binary files /dev/null and b/samples/MobileChatRoom/assets/6-2-3-read-private.png differ
diff --git a/samples/MobileChatRoom/assets/7-1-1-overview-interface.png b/samples/MobileChatRoom/assets/7-1-1-overview-interface.png
new file mode 100644
index 00000000..82a47ab7
Binary files /dev/null and b/samples/MobileChatRoom/assets/7-1-1-overview-interface.png differ