├── .gitignore ├── Android ├── .gitignore ├── .idea │ ├── .gitignore │ ├── .name │ ├── codeStyles │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ ├── gradle.xml │ ├── jarRepositories.xml │ └── misc.xml ├── app │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src │ │ ├── androidTest │ │ └── java │ │ │ └── io │ │ │ └── agora │ │ │ └── typing │ │ │ └── ExampleInstrumentedTest.kt │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ │ └── io │ │ │ │ └── agora │ │ │ │ └── typing │ │ │ │ ├── App.kt │ │ │ │ ├── base │ │ │ │ ├── BuildConfig.kt │ │ │ │ ├── Logger.kt │ │ │ │ ├── Message.kt │ │ │ │ └── Service.kt │ │ │ │ ├── server │ │ │ │ └── server.kt │ │ │ │ └── ui │ │ │ │ ├── ChatActivity.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── chat │ │ │ │ ├── ChatFragment.kt │ │ │ │ └── ChatModel.kt │ │ │ │ └── login │ │ │ │ ├── LoginFragment.kt │ │ │ │ └── LoginModel.kt │ │ └── res │ │ │ ├── anim │ │ │ └── shake.xml │ │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── drawable │ │ │ ├── button.xml │ │ │ ├── cursor.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── offline.xml │ │ │ ├── online.xml │ │ │ └── round.xml │ │ │ ├── layout │ │ │ ├── chat_fragment.xml │ │ │ ├── login_fragment.xml │ │ │ └── main_activity.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ │ ├── values-night │ │ │ └── themes.xml │ │ │ └── values │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ └── test │ │ └── java │ │ └── io │ │ └── agora │ │ └── typing │ │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle ├── README.md ├── README.zh.md └── iOS ├── .gitignore ├── Podfile ├── Typing.xcodeproj └── project.pbxproj └── Typing ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ └── Contents.json ├── Contents.json └── background.imageset │ ├── Contents.json │ └── background.png ├── Base ├── Logger.swift ├── Message.swift └── Service.swift ├── Component ├── ChatTextField.swift ├── InputView.swift ├── KeyboardAdaptive.swift ├── RoundButton.swift ├── Shake.swift └── TouchDown.swift ├── Extension.swift ├── Info.plist ├── KeyCenter.swift ├── Preview Content └── Preview Assets.xcassets │ └── Contents.json ├── Server └── Server.swift ├── Typing.entitlements ├── TypingApp.swift └── View ├── Chat ├── ChatModel.swift └── ChatView.swift └── Login ├── LoginModel.swift └── LoginView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata 2 | .DS_Store 3 | AgoraRtcKit.framework 4 | -------------------------------------------------------------------------------- /Android/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /Android/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /Android/.idea/.name: -------------------------------------------------------------------------------- 1 | Typing -------------------------------------------------------------------------------- /Android/.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 22 | 23 | 135 | 136 | 138 | 139 | -------------------------------------------------------------------------------- /Android/.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /Android/.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Android/.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 21 | 22 | -------------------------------------------------------------------------------- /Android/.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /Android/.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /Android/app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /Android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdkVersion 30 8 | buildToolsVersion "30.0.3" 9 | 10 | defaultConfig { 11 | applicationId "io.agora.typing" 12 | minSdkVersion 16 13 | targetSdkVersion 30 14 | versionCode 1 15 | versionName "1.0" 16 | multiDexEnabled true 17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 18 | 19 | ndk { 20 | abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' 21 | } 22 | } 23 | 24 | buildTypes { 25 | debug { 26 | minifyEnabled true 27 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 28 | } 29 | release { 30 | minifyEnabled true 31 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 32 | } 33 | } 34 | 35 | sourceSets { 36 | main { 37 | jniLibs.srcDirs = ['src/main/jniLibs'] 38 | } 39 | } 40 | 41 | compileOptions { 42 | sourceCompatibility JavaVersion.VERSION_1_8 43 | targetCompatibility JavaVersion.VERSION_1_8 44 | } 45 | kotlinOptions { 46 | jvmTarget = '1.8' 47 | } 48 | } 49 | 50 | dependencies { 51 | implementation 'androidx.multidex:multidex:2.0.1' 52 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 53 | implementation "androidx.core:core-ktx:$core_ktx" 54 | implementation "androidx.appcompat:appcompat:$appcompat" 55 | implementation "com.google.android.material:material:$material" 56 | implementation "androidx.constraintlayout:constraintlayout:$constraintlayout" 57 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_livedata_ktx" 58 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_viewmodel_ktx" 59 | implementation "io.agora.rtm:rtm-sdk:$rtm_sdk" 60 | testImplementation 'junit:junit:4.+' 61 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 62 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 63 | } -------------------------------------------------------------------------------- /Android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -keep class io.agora.common.**{*;} 23 | -keep class io.agora.rtm.**{*;} -------------------------------------------------------------------------------- /Android/app/src/androidTest/java/io/agora/typing/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.agora.typing 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.agora.openchat", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /Android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/App.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing 2 | 3 | import androidx.multidex.MultiDexApplication 4 | 5 | class App : MultiDexApplication() { 6 | companion object { 7 | lateinit var instance: App 8 | } 9 | 10 | override fun onCreate() { 11 | super.onCreate() 12 | instance = this 13 | } 14 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/base/BuildConfig.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.base 2 | 3 | class BuildConfig { 4 | companion object { 5 | val appId: String = <#Your App Id#> 6 | val token: String? = <#Temp Access Token#> 7 | } 8 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/base/Logger.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.base 2 | 3 | import android.util.Log 4 | 5 | enum class LogLevel { 6 | Info, Warning, Error 7 | } 8 | 9 | class Logger { 10 | companion object { 11 | const val debug = true 12 | const val tag = "chat" 13 | 14 | fun log(message: String, level: LogLevel) { 15 | if (!debug && level != LogLevel.Error) { 16 | return 17 | } 18 | when (level) { 19 | LogLevel.Info -> Log.d(tag, "$message (${Thread.currentThread().name})") 20 | LogLevel.Warning -> Log.w(tag, message) 21 | LogLevel.Error -> Log.e(tag, message) 22 | } 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/base/Message.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.base 2 | 3 | enum class MessageType { 4 | Text, Vibrate 5 | } 6 | 7 | class Message { 8 | 9 | companion object { 10 | fun text(raw: String): Message { 11 | return Message(MessageType.Text, raw) 12 | } 13 | 14 | fun vibrate(): Message { 15 | return Message(MessageType.Vibrate, "") 16 | } 17 | } 18 | 19 | val type: MessageType 20 | val data: String 21 | 22 | constructor(raw: String) { 23 | if (raw.startsWith("vibrate://")) { 24 | this.type = MessageType.Vibrate 25 | this.data = "" 26 | } else { 27 | this.type = MessageType.Text 28 | this.data = raw.replace("^text://".toRegex(), "") 29 | } 30 | } 31 | 32 | private constructor(type: MessageType, data: String) { 33 | this.type = type 34 | this.data = data 35 | } 36 | 37 | override fun toString(): String { 38 | return when (type) { 39 | MessageType.Text -> "text://${this.data}" 40 | MessageType.Vibrate -> "vibrate://${this.data}" 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/base/Service.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.base 2 | 3 | import io.agora.rtm.RtmMessage 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | data class Result(val success: Boolean, val data: T? = null, val message: String? = null) 7 | data class UserMessage(val name: String? = null, val message: RtmMessage? = null) 8 | 9 | interface Service { 10 | fun login(user: String): Flow> 11 | fun logout(): Flow> 12 | fun sendMessage(message: Message, toUser: String): Flow> 13 | fun subscribeUserOnlineState(user: String): Flow> 14 | fun subscribeFriendMessage(user: String): Flow> 15 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/server/server.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.server 2 | 3 | import io.agora.typing.App 4 | import io.agora.typing.base.* 5 | import io.agora.rtm.* 6 | import kotlinx.coroutines.* 7 | import kotlinx.coroutines.channels.ConflatedBroadcastChannel 8 | import kotlinx.coroutines.channels.awaitClose 9 | import kotlinx.coroutines.flow.* 10 | import java.util.concurrent.CancellationException 11 | 12 | enum class UserStatus { 13 | Online, Offline 14 | } 15 | 16 | data class User(val name: String, var status: UserStatus = UserStatus.Offline) 17 | 18 | class WithErrorInfoError(override val message: String, val info: ErrorInfo? = null) : CancellationException(message) 19 | 20 | @FlowPreview 21 | @ExperimentalCoroutinesApi 22 | class Server : Service, RtmClientListener { 23 | 24 | companion object { 25 | val Instance: Server by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) { 26 | Server() 27 | } 28 | } 29 | 30 | private var account: User? = null 31 | private val agoraRtmKit = RtmClient.createInstance(App.instance, BuildConfig.appId, this) 32 | 33 | override fun login(user: String): Flow> { 34 | Logger.log("login with id:$user", LogLevel.Info) 35 | var needLogout = false 36 | if (account?.status == UserStatus.Online) { 37 | if (account?.name == user) { 38 | return flowOf(Result(true)) 39 | } else { 40 | needLogout = true 41 | } 42 | } 43 | return flowOf(needLogout).flatMapConcat { 44 | if (!it) { 45 | flowOf(Result(true)) 46 | } else { 47 | logout() 48 | } 49 | }.flatMapConcat { 50 | if (it.success) { 51 | callbackFlow { 52 | val callback = object : ResultCallback { 53 | override fun onSuccess(p0: Void?) { 54 | account = User(user, UserStatus.Online) 55 | offer(Result(true)) 56 | channel.close() 57 | } 58 | 59 | override fun onFailure(error: ErrorInfo?) { 60 | val errorCode = error?.errorCode 61 | val message = error?.errorDescription ?: "unknown error" 62 | Logger.log("login fail ($errorCode $message)", LogLevel.Error) 63 | cancel(WithErrorInfoError(message, error)) 64 | } 65 | } 66 | agoraRtmKit.login(BuildConfig.token, user, callback) 67 | awaitClose() 68 | } 69 | } else { 70 | flowOf(it) 71 | } 72 | } 73 | } 74 | 75 | override fun logout(): Flow> { 76 | Logger.log("logout", LogLevel.Info) 77 | return if (account?.status != UserStatus.Online) { 78 | flowOf(Result(true)) 79 | } else { 80 | callbackFlow { 81 | val callback = object : ResultCallback { 82 | override fun onSuccess(p0: Void?) { 83 | account = null 84 | offer(Result(true)) 85 | channel.close() 86 | } 87 | 88 | override fun onFailure(error: ErrorInfo?) { 89 | val errorCode = error?.errorCode 90 | val message = error?.errorDescription ?: "unknown error" 91 | Logger.log("logout fail ($errorCode $message)", LogLevel.Error) 92 | cancel(WithErrorInfoError(message, error)) 93 | channel.close() 94 | } 95 | } 96 | agoraRtmKit.logout(callback) 97 | awaitClose() 98 | } 99 | } 100 | } 101 | 102 | override fun subscribeUserOnlineState(user: String): Flow> { 103 | val callback = object : ResultCallback { 104 | override fun onSuccess(p0: Void?) { 105 | GlobalScope.launch(Dispatchers.Default) { 106 | peerStatusChangeChannel.send(Result(true, User(user))) 107 | } 108 | } 109 | 110 | override fun onFailure(error: ErrorInfo?) { 111 | GlobalScope.launch(Dispatchers.Default) { 112 | peerStatusChangeChannel.send( 113 | Result( 114 | false, 115 | User(user), 116 | message = error?.errorDescription ?: "unknown error" 117 | ) 118 | ) 119 | } 120 | } 121 | } 122 | agoraRtmKit.subscribePeersOnlineStatus(setOf(user), callback) 123 | return peerStatusChangeChannel.asFlow().filter { result -> 124 | result.data?.name == user 125 | }.map { result -> 126 | Logger.log("user: ${result.data?.name} status: ${result.data?.status}", LogLevel.Info) 127 | Result( 128 | result.success, 129 | data = result.data?.status == UserStatus.Online, 130 | message = result.message 131 | ) 132 | }.flowOn(Dispatchers.IO) 133 | } 134 | 135 | override fun sendMessage( 136 | message: Message, 137 | toUser: String 138 | ): Flow> { 139 | return callbackFlow { 140 | val callback = object : ResultCallback { 141 | override fun onSuccess(p0: Void?) { 142 | offer(Result(true)) 143 | channel.close() 144 | } 145 | 146 | override fun onFailure(error: ErrorInfo?) { 147 | val errorCode = error?.errorCode 148 | val errorDescription = error?.errorDescription ?: "unknown error" 149 | Logger.log("sendMessage ($errorCode $errorDescription)", LogLevel.Error) 150 | if (errorCode == RtmStatusCode.PeerMessageError.PEER_MESSAGE_ERR_CACHED_BY_SERVER) { 151 | offer(Result(true, errorCode)) 152 | } else { 153 | //cancel(WithErrorInfoError(message, error)) 154 | offer(Result(false, errorCode, errorDescription)) 155 | } 156 | channel.close() 157 | } 158 | } 159 | 160 | val status = peersStatus[toUser] ?: PeerOnlineState.UNREACHABLE 161 | val option = SendMessageOptions() 162 | option.enableOfflineMessaging = status != PeerOnlineState.ONLINE 163 | agoraRtmKit.sendMessageToPeer( 164 | toUser, 165 | agoraRtmKit.createMessage(message.toString()), 166 | option, 167 | callback 168 | ) 169 | Logger.log("sendMessage to: $toUser", LogLevel.Info) 170 | awaitClose() 171 | } 172 | } 173 | 174 | override fun subscribeFriendMessage(user: String): Flow> { 175 | return peerMessageChannel.asFlow().filter { result -> 176 | result.success && result.data?.name == user && result.data.message != null 177 | }.flowOn(Dispatchers.IO) 178 | } 179 | 180 | override fun onTokenExpired() { 181 | Logger.log("onTokenExpired", LogLevel.Info) 182 | } 183 | 184 | private val peersStatus = HashMap() 185 | private val peerStatusChangeChannel = ConflatedBroadcastChannel>() 186 | 187 | override fun onPeersOnlineStatusChanged(peersStatus: MutableMap?) { 188 | Logger.log("onPeersOnlineStatusChanged", LogLevel.Info) 189 | peersStatus?.forEach { item -> 190 | val peerId = item.key 191 | val status = 192 | if (item.value == PeerOnlineState.ONLINE) UserStatus.Online else UserStatus.Offline 193 | peersStatus[peerId] = item.value 194 | peerStatusChangeChannel.offer(Result(true, data = User(peerId, status))) 195 | } 196 | } 197 | 198 | override fun onConnectionStateChanged(state: Int, reason: Int) { 199 | Logger.log("onConnectionStateChanged", LogLevel.Info) 200 | } 201 | 202 | private val peerMessageChannel = ConflatedBroadcastChannel>() 203 | 204 | override fun onMessageReceived(message: RtmMessage?, peerId: String?) { 205 | Logger.log("onMessageReceived from:$peerId", LogLevel.Info) 206 | peerMessageChannel.offer(Result(true, UserMessage(peerId, message))) 207 | } 208 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/ui/ChatActivity.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.ui 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import androidx.appcompat.app.AppCompatActivity 6 | import android.os.Bundle 7 | import io.agora.typing.R 8 | import io.agora.typing.base.LogLevel 9 | import io.agora.typing.base.Logger 10 | import io.agora.typing.server.Server 11 | import io.agora.typing.ui.chat.ChatFragment 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.flow.collect 14 | 15 | @ExperimentalCoroutinesApi 16 | @FlowPreview 17 | class ChatActivity : AppCompatActivity() { 18 | 19 | companion object { 20 | const val FRIEND_NAME = "friend_name" 21 | fun newInstance(context: Context, friend: String): Intent { 22 | return Intent(context, ChatActivity::class.java).apply { 23 | putExtra(FRIEND_NAME, friend) 24 | } 25 | } 26 | } 27 | 28 | override fun onCreate(savedInstanceState: Bundle?) { 29 | super.onCreate(savedInstanceState) 30 | setContentView(R.layout.main_activity) 31 | if (savedInstanceState == null) { 32 | val friend = intent.getStringExtra(FRIEND_NAME) 33 | if (friend.isNullOrEmpty()) { 34 | finish() 35 | } else { 36 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 37 | supportActionBar?.setDisplayShowTitleEnabled(true) 38 | supportActionBar?.title = "chat($friend)" 39 | supportFragmentManager.beginTransaction() 40 | .replace(R.id.container, ChatFragment.newInstance(friend)) 41 | .commitNow() 42 | } 43 | } 44 | } 45 | 46 | override fun onSupportNavigateUp(): Boolean { 47 | GlobalScope.launch(Dispatchers.Main) { 48 | Server.Instance.logout().collect { 49 | Logger.log("logout", LogLevel.Info) 50 | } 51 | } 52 | finish() 53 | return true 54 | } 55 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.ui 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import android.os.Bundle 5 | import io.agora.typing.R 6 | import io.agora.typing.ui.login.LoginFragment 7 | 8 | class MainActivity : AppCompatActivity() { 9 | 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | setContentView(R.layout.main_activity) 13 | if (savedInstanceState == null) { 14 | supportFragmentManager.beginTransaction() 15 | .replace(R.id.container, LoginFragment.newInstance()) 16 | .commitNow() 17 | } 18 | 19 | supportActionBar?.hide() 20 | } 21 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/ui/chat/ChatFragment.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.ui.chat 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.os.Bundle 6 | import android.os.VibrationEffect 7 | import android.os.Vibrator 8 | import android.view.LayoutInflater 9 | import android.view.View 10 | import android.view.ViewGroup 11 | import android.view.animation.Animation 12 | import android.view.animation.AnimationUtils 13 | import android.widget.EditText 14 | import android.widget.TextView 15 | import androidx.core.widget.addTextChangedListener 16 | import androidx.fragment.app.Fragment 17 | import androidx.lifecycle.ViewModelProvider 18 | import androidx.lifecycle.observe 19 | import io.agora.typing.R 20 | import io.agora.typing.base.Message 21 | import io.agora.typing.base.MessageType 22 | import com.google.android.material.snackbar.Snackbar 23 | import kotlinx.coroutines.* 24 | 25 | 26 | @ExperimentalCoroutinesApi 27 | @FlowPreview 28 | class ChatFragment(var friend: String) : Fragment() { 29 | 30 | companion object { 31 | fun newInstance(friend: String) = ChatFragment(friend) 32 | } 33 | 34 | private lateinit var messageBox: View 35 | private lateinit var viewModel: ChatModel 36 | private lateinit var inputMessage: EditText 37 | private lateinit var messageView: TextView 38 | private lateinit var onlineView: View 39 | 40 | private lateinit var animShake: Animation 41 | 42 | override fun onCreateView( 43 | inflater: LayoutInflater, container: ViewGroup?, 44 | savedInstanceState: Bundle? 45 | ): View { 46 | val root = inflater.inflate(R.layout.chat_fragment, container, false) 47 | messageBox = root.findViewById(R.id.frameLayout) 48 | inputMessage = root.findViewById(R.id.inputMessage) 49 | messageView = root.findViewById(R.id.message) 50 | onlineView = root.findViewById(R.id.online) 51 | return root 52 | } 53 | 54 | override fun onActivityCreated(savedInstanceState: Bundle?) { 55 | super.onActivityCreated(savedInstanceState) 56 | viewModel = ViewModelProvider(this).get(ChatModel::class.java) 57 | animShake = AnimationUtils.loadAnimation(context, R.anim.shake) 58 | 59 | messageBox.setOnClickListener { 60 | messageBox.startAnimation(animShake) 61 | viewModel.vibrate() 62 | } 63 | 64 | inputMessage.setHorizontallyScrolling(false) 65 | inputMessage.maxLines = Int.MAX_VALUE 66 | inputMessage.setOnEditorActionListener { _, _, _ -> 67 | inputMessage.text = null 68 | viewModel.sendMessage("") 69 | true 70 | } 71 | 72 | inputMessage.addTextChangedListener { 73 | viewModel.sendMessage(it.toString()) 74 | } 75 | 76 | viewModel.onlineStatus(friend).observe(this) { result -> 77 | if (result.success) { 78 | result.data?.let { 79 | onlineView.setBackgroundResource(if (it) R.drawable.online else R.drawable.offline) 80 | } 81 | } else { 82 | result.message?.let { 83 | viewModel.snackBar.value = it 84 | } 85 | } 86 | } 87 | 88 | viewModel.receivedMessage(friend).observe(this) { result -> 89 | if (result.success) { 90 | val message = Message(result.data?.message?.text ?: "") 91 | when (message.type) { 92 | MessageType.Vibrate -> vibrate() 93 | MessageType.Text -> messageView.text = message.data 94 | } 95 | } else { 96 | result.message?.let { 97 | viewModel.snackBar.value = it 98 | } 99 | } 100 | } 101 | 102 | viewModel.onInputMessage(friend).observe(this) { result -> 103 | if (!result.success) { 104 | result.message?.let { 105 | viewModel.snackBar.value = it 106 | } 107 | } 108 | } 109 | 110 | viewModel.onVibrateMessage(friend).observe(this) { result -> 111 | if (!result.success) { 112 | result.message?.let { 113 | viewModel.snackBar.value = it 114 | } 115 | } 116 | } 117 | 118 | // Show a snackbar whenever the [ViewModel.snackbar] is updated a non-null value 119 | viewModel.snackBar.observe(this) { text -> 120 | text?.let { 121 | this.view?.let { it1 -> Snackbar.make(it1, it, Snackbar.LENGTH_SHORT).show() } 122 | viewModel.onSnackbarShown() 123 | } 124 | } 125 | } 126 | 127 | private fun vibrate() { 128 | inputMessage.startAnimation(animShake) 129 | val vibrator = context?.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator 130 | if (Build.VERSION.SDK_INT >= 26) { 131 | vibrator.vibrate(VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE)) 132 | } else { 133 | vibrator.vibrate(200) 134 | } 135 | } 136 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/ui/chat/ChatModel.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.ui.chat 2 | 3 | import androidx.lifecycle.* 4 | import io.agora.typing.base.Message 5 | import io.agora.typing.server.Server 6 | import kotlinx.coroutines.* 7 | import kotlinx.coroutines.channels.ConflatedBroadcastChannel 8 | import kotlinx.coroutines.flow.* 9 | 10 | @FlowPreview 11 | @ExperimentalCoroutinesApi 12 | class ChatModel : ViewModel() { 13 | 14 | private var inputMessage = ConflatedBroadcastChannel() 15 | private var vibrateMessage = ConflatedBroadcastChannel() 16 | /** 17 | * Request a snackbar to display a string. 18 | */ 19 | val snackBar = MutableLiveData() 20 | 21 | /** 22 | * Called immediately after the UI shows the snackbar. 23 | */ 24 | fun onSnackbarShown() { 25 | snackBar.value = null 26 | } 27 | 28 | fun sendMessage(message: String) { 29 | inputMessage.offer(message) 30 | } 31 | 32 | fun vibrate() { 33 | vibrateMessage.offer(true) 34 | } 35 | 36 | fun receivedMessage(name: String) = 37 | Server.Instance.subscribeFriendMessage(name).asLiveData(Dispatchers.Main) 38 | 39 | fun onlineStatus(name: String) = 40 | Server.Instance.subscribeUserOnlineState(name).asLiveData(Dispatchers.Main) 41 | 42 | fun onInputMessage(name: String) = 43 | inputMessage 44 | .asFlow() 45 | .distinctUntilChanged() 46 | .debounce(50) 47 | .flatMapMerge { message -> 48 | Server.Instance.sendMessage(Message.text(message), name) 49 | } 50 | .flowOn(Dispatchers.Default) 51 | .asLiveData(Dispatchers.Main) 52 | 53 | fun onVibrateMessage(name: String) = 54 | vibrateMessage 55 | .asFlow() 56 | .debounce(200) 57 | .flatMapMerge { 58 | Server.Instance.sendMessage(Message.vibrate(), name) 59 | } 60 | .flowOn(Dispatchers.Default) 61 | .asLiveData(Dispatchers.Main) 62 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/ui/login/LoginFragment.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.ui.login 2 | 3 | import androidx.lifecycle.ViewModelProvider 4 | import android.os.Bundle 5 | import androidx.fragment.app.Fragment 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import android.widget.Button 10 | import android.widget.EditText 11 | import android.widget.ProgressBar 12 | import androidx.lifecycle.observe 13 | import io.agora.typing.ui.ChatActivity 14 | import io.agora.typing.R 15 | import com.google.android.material.snackbar.Snackbar 16 | import kotlinx.coroutines.ExperimentalCoroutinesApi 17 | import kotlinx.coroutines.FlowPreview 18 | 19 | @ExperimentalCoroutinesApi 20 | @FlowPreview 21 | class LoginFragment : Fragment() { 22 | 23 | companion object { 24 | fun newInstance() = LoginFragment() 25 | } 26 | 27 | private lateinit var viewModel: LoginModel 28 | private lateinit var userInput: EditText 29 | private lateinit var friendInput: EditText 30 | private lateinit var startButton: Button 31 | private lateinit var progressBar: ProgressBar 32 | 33 | override fun onCreateView( 34 | inflater: LayoutInflater, container: ViewGroup?, 35 | savedInstanceState: Bundle? 36 | ): View { 37 | val root = inflater.inflate(R.layout.login_fragment, container, false) 38 | userInput = root.findViewById(R.id.user) 39 | friendInput = root.findViewById(R.id.friend) 40 | startButton = root.findViewById(R.id.start) 41 | progressBar = root.findViewById(R.id.connecting) 42 | return root 43 | } 44 | 45 | override fun onActivityCreated(savedInstanceState: Bundle?) { 46 | super.onActivityCreated(savedInstanceState) 47 | viewModel = ViewModelProvider(this).get(LoginModel::class.java) 48 | 49 | startButton.setOnClickListener { 50 | viewModel.userName = userInput.text.toString() 51 | viewModel.friendName = friendInput.text.toString() 52 | viewModel.onClickLoginButton() 53 | } 54 | 55 | viewModel.online.observe(this) { success -> 56 | if (success) { 57 | //startActivity() 58 | context?.let { _context -> 59 | viewModel.friendName?.let { _name -> 60 | startActivity(ChatActivity.newInstance(_context, _name)) 61 | } 62 | } 63 | } 64 | } 65 | 66 | viewModel.spinner.observe(this) { value -> 67 | value.let { show -> 68 | userInput.isEnabled = !show 69 | friendInput.isEnabled = !show 70 | progressBar.visibility = if (show) View.VISIBLE else View.INVISIBLE 71 | startButton.visibility = if (show) View.INVISIBLE else View.VISIBLE 72 | } 73 | } 74 | 75 | // Show a snackbar whenever the [ViewModel.snackbar] is updated a non-null value 76 | viewModel.snackbar.observe(this) { text -> 77 | text?.let { 78 | this.view?.let { it1 -> Snackbar.make(it1, it, Snackbar.LENGTH_SHORT).show() } 79 | viewModel.onSnackbarShown() 80 | } 81 | } 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /Android/app/src/main/java/io/agora/typing/ui/login/LoginModel.kt: -------------------------------------------------------------------------------- 1 | package io.agora.typing.ui.login 2 | 3 | import io.agora.typing.base.Result 4 | import io.agora.typing.server.Server 5 | import kotlinx.coroutines.* 6 | import kotlinx.coroutines.flow.* 7 | import androidx.lifecycle.* 8 | import io.agora.typing.base.LogLevel 9 | import io.agora.typing.base.Logger 10 | import io.agora.typing.server.WithErrorInfoError 11 | 12 | @FlowPreview 13 | @ExperimentalCoroutinesApi 14 | class LoginModel : ViewModel() { 15 | 16 | var userName: String? = null 17 | var friendName: String? = null 18 | 19 | private val _snackBar = MutableLiveData() 20 | private var _spinner = MutableLiveData() 21 | private var _online = MutableLiveData() 22 | 23 | /** 24 | * Request a snackbar to display a string. 25 | */ 26 | val snackbar: LiveData 27 | get() = _snackBar 28 | 29 | /** 30 | * Show a loading spinner if true 31 | */ 32 | val spinner: LiveData 33 | get() = _spinner 34 | 35 | /** 36 | * notify login action result 37 | */ 38 | val online: LiveData 39 | get() = _online 40 | 41 | /** 42 | * Called immediately after the UI shows the snackbar. 43 | */ 44 | fun onSnackbarShown() { 45 | _snackBar.value = null 46 | } 47 | 48 | fun onClickLoginButton() { 49 | loginAction() 50 | } 51 | 52 | private fun loginAction() = launchDataLoad { 53 | login().collect { 54 | Logger.log("login success", LogLevel.Info) 55 | _snackBar.value = "Login Success!" 56 | _online.value = true 57 | } 58 | } 59 | 60 | private suspend fun login(): Flow> { 61 | return withContext(Dispatchers.IO) { 62 | if (userName?.isEmpty() == true || friendName?.isEmpty() == true) { 63 | throw WithErrorInfoError("Input user's name or friend's name!") 64 | } else { 65 | Server.Instance.login(userName!!) 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * Helper function to call a data load function with a loading spinner, errors will trigger a 72 | * snackbar. 73 | * 74 | * By marking `block` as `suspend` this creates a suspend lambda which can call suspend 75 | * functions. 76 | * 77 | * @param block lambda to actually load data. It is called in the viewModelScope. Before calling the 78 | * lambda the loading spinner will display, after completion or error the loading 79 | * spinner will stop 80 | */ 81 | private fun launchDataLoad(block: suspend () -> Unit): Unit { 82 | viewModelScope.launch { 83 | try { 84 | _spinner.value = true 85 | block() 86 | } catch (error: WithErrorInfoError) { 87 | _snackBar.value = error.message 88 | } finally { 89 | _spinner.value = false 90 | } 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /Android/app/src/main/res/anim/shake.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /Android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /Android/app/src/main/res/drawable/button.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /Android/app/src/main/res/drawable/cursor.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /Android/app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /Android/app/src/main/res/drawable/offline.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Android/app/src/main/res/drawable/online.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Android/app/src/main/res/drawable/round.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /Android/app/src/main/res/layout/chat_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 24 | 25 | 33 | 34 | 40 | 41 | 42 | 43 | 52 | 53 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Android/app/src/main/res/layout/login_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 22 | 23 | 40 | 41 | 42 | 43 | 44 | 57 | 58 | 72 | 73 | 90 | 91 | 101 | 102 |