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 @@ + + + + + + + + + + + + + +