├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── kotlin │ └── com │ │ └── pusher │ │ └── chatkitdemo │ │ ├── ChatKitDemoApp.kt │ │ ├── EntryActivity.kt │ │ ├── LangUtil.kt │ │ ├── MainActivity.kt │ │ ├── arch │ │ └── viewModels.kt │ │ ├── navigation │ │ └── navigation.kt │ │ ├── parallel │ │ ├── Channels.kt │ │ └── LifecycleReceiverChannel.kt │ │ ├── preferences.kt │ │ ├── recyclerview │ │ └── DataAdapter.kt │ │ ├── room │ │ ├── RoomActivity.kt │ │ ├── RoomFragment.kt │ │ └── rooms.kt │ │ └── views.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── layout │ ├── activity_entry.xml │ ├── activity_entry_loaded.xml │ ├── activity_main.xml │ ├── activity_room.xml │ ├── fragment_room.xml │ ├── fragment_room_loaded.xml │ ├── include_error.xml │ ├── include_loading.xml │ ├── item_message.xml │ ├── item_message_pending.xml │ └── item_room.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 │ ├── colors.xml │ ├── dimens.xml │ ├── ids.xml │ ├── strings.xml │ └── styles.xml ├── build.gradle ├── gradle.properties └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEA 2 | *.iml 3 | .idea/* 4 | !.idea/codeStyleSettings.xml 5 | !.idea/copyright 6 | 7 | # Gradle 8 | .gradle 9 | build 10 | /reports 11 | 12 | # Gradle Android 13 | /local.properties 14 | /captures 15 | gradle 16 | gradlew 17 | gradlew.bat 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: android 2 | 3 | # https://stackoverflow.com/a/47726910/458365 4 | before_install: 5 | - yes | sdkmanager "platforms;android-27" 6 | 7 | before_cache: 8 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 9 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ 10 | 11 | cache: 12 | directories: 13 | - ${HOME}/.gradle/caches/ 14 | - ${HOME}/.gradle/wrapper/ 15 | - ${HOME}/.android/build-cache 16 | - ${TRAVIS_BUILD_DIR}/gradle/caches/ 17 | - ${TRAVIS_BUILD_DIR}/gradle/wrapper/dists/ 18 | - ${TRAVIS_BUILD_DIR}/android-sdk/extras/ 19 | 20 | script: ./gradlew build --stacktrace 21 | 22 | android: 23 | components: 24 | - tools 25 | - build-tools-27.0.3 26 | - android-27 27 | - add-on 28 | - extra 29 | licenses: 30 | - 'android-sdk-preview-.+' 31 | - 'android-sdk-license-.+' 32 | - 'google-gdk-license-.+' 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Pusher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Slack Clone (using [Chatkit](http://github.com/pusher/chatkit-android)) 2 | 3 | [![Twitter](https://img.shields.io/badge/twitter-@Pusher-blue.svg?style=flat)](http://twitter.com/Pusher) 4 | [![GitHub license](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://raw.githubusercontent.com/pusher/android-slack-clone/master/LICENSE) 5 | [![codecov](https://codecov.io/gh/pusher/android-slack-clone/branch/master/graph/badge.svg)](https://codecov.io/gh/pusher/android-slack-clone) 6 | [![Travis branch](https://img.shields.io/travis/pusher/android-slack-clone/master.svg)](https://travis-ci.org/pusher/android-slack-clone) 7 | 8 | 9 | # WIP (watch this space) 10 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | 5 | android { 6 | compileSdkVersion 28 7 | buildToolsVersion '28.0.3' 8 | 9 | defaultConfig { 10 | applicationId "com.pusher.chatkitdemo" 11 | minSdkVersion 19 12 | targetSdkVersion 28 13 | versionCode 1 14 | versionName "1.0" 15 | multiDexEnabled true 16 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 17 | 18 | } 19 | 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 24 | } 25 | debug { 26 | testCoverageEnabled true 27 | } 28 | } 29 | 30 | sourceSets { 31 | main.java.srcDirs += 'src/main/kotlin' 32 | test.java.srcDirs += 'src/test/kotlin' 33 | androidTest.java.srcDirs += 'src/androidTest/kotlin' 34 | } 35 | 36 | } 37 | 38 | androidExtensions { 39 | experimental = true 40 | } 41 | 42 | dependencies { 43 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 44 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" 45 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" 46 | implementation "com.android.support:appcompat-v7:$android_support_version" 47 | implementation "com.android.support:design:$android_support_version" 48 | implementation "com.android.support.constraint:constraint-layout:$constraints_layout_version" 49 | implementation "android.arch.lifecycle:extensions:$android_arch_version" 50 | implementation "android.arch.lifecycle:common:$android_arch_version" 51 | implementation "com.android.support:customtabs:$android_support_version" 52 | implementation "com.pusher:chatkit-android:$chatkit_android_version" 53 | implementation 'com.google.firebase:firebase-core:16.0.9' 54 | implementation 'com.google.firebase:firebase-messaging:18.0.0' 55 | implementation 'com.android.support:multidex:1.0.3' 56 | implementation 'com.android.support:coordinatorlayout:28.0.0' 57 | 58 | testImplementation "junit:junit:$junit_legacy_version" 59 | 60 | androidTestImplementation "com.android.support.test:runner:$espresso_runner_version" 61 | androidTestImplementation "com.android.support.test.espresso:espresso-core:$espresso_version" 62 | } 63 | 64 | apply plugin: 'com.google.gms.google-services' -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 32 | 33 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 50 | 51 | 52 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/ChatKitDemoApp.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.support.v4.app.Fragment 6 | import com.pusher.chatkit.AndroidChatkitDependencies 7 | import com.pusher.chatkit.ChatManager 8 | import com.pusher.chatkit.ChatkitTokenProvider 9 | import com.pusher.chatkit.CurrentUser 10 | import com.pusher.platform.logger.AndroidLogger 11 | import com.pusher.platform.logger.LogLevel 12 | import com.pusher.platform.logger.Logger 13 | import com.pusher.util.Result 14 | import elements.Error 15 | import kotlinx.coroutines.ExperimentalCoroutinesApi 16 | import kotlinx.coroutines.channels.BroadcastChannel 17 | import kotlinx.coroutines.channels.Channel 18 | import kotlin.properties.Delegates 19 | 20 | private const val INSTANCE_LOCATOR = "v1:us1:05f46048-3763-4482-9cfe-51ff327c3f29" 21 | private const val TOKEN_PROVIDER_ENDPOINT = "https://chatkit-demoauth-server.herokuapp.com/token" 22 | 23 | val Context.app: ChatKitDemoApp 24 | get() = when (applicationContext) { 25 | null -> error("Application context is null") 26 | is ChatKitDemoApp -> applicationContext as ChatKitDemoApp 27 | else -> error("Application context ($applicationContext) is not ${nameOf()}") 28 | } 29 | 30 | val Fragment.app: ChatKitDemoApp 31 | get() = context!!.app 32 | 33 | class ChatKitDemoApp : Application() { 34 | 35 | companion object { 36 | private var maybeApp: ChatKitDemoApp? = null 37 | val app get() = checkNotNull(maybeApp) 38 | } 39 | 40 | val logger: Logger by lazy { AndroidLogger(LogLevel.VERBOSE) } 41 | private val userPreferences by lazy { UserPreferences(this) } 42 | 43 | var userId: String? 44 | get() = userPreferences.userId 45 | set(value) { 46 | userPreferences.userId = value 47 | tryConnect(value, token) 48 | } 49 | 50 | var token: String? 51 | get() = userPreferences.token 52 | set(value) { 53 | userPreferences.token = value 54 | tryConnect(userId, value) 55 | } 56 | 57 | @ExperimentalCoroutinesApi 58 | private fun tryConnect(userId: String?, token: String?) = when { 59 | userId != null && token != null -> connect(userId, token) 60 | else -> Unit // ignore 61 | } 62 | 63 | private lateinit var chat: ChatManager 64 | 65 | override fun onCreate() = super.onCreate().also { 66 | maybeApp = this 67 | } 68 | 69 | @ExperimentalCoroutinesApi 70 | private fun connect(userId: String, token: String) { 71 | 72 | val dependencies = AndroidChatkitDependencies( 73 | tokenProvider = ChatkitTokenProvider( 74 | endpoint = "$TOKEN_PROVIDER_ENDPOINT?token=$token", 75 | userId = userId 76 | ), 77 | context = applicationContext 78 | ) 79 | 80 | chat = ChatManager( 81 | instanceLocator = INSTANCE_LOCATOR, 82 | userId = userId, 83 | dependencies = dependencies 84 | ) 85 | 86 | chat.connect { result -> 87 | when (result) { 88 | is Result.Success -> { 89 | result.value.let { user -> 90 | currentUser = user 91 | user.enablePushNotifications { result -> 92 | when (result) { 93 | is Result.Success -> { 94 | // Push Notifications Service Enabled! 95 | } 96 | 97 | is Error -> logger.error("Error starting Push Notifications") 98 | } 99 | } 100 | } 101 | } 102 | 103 | is Error -> logger.error("Failed to connect and get the current user") 104 | } 105 | } 106 | } 107 | 108 | @ExperimentalCoroutinesApi 109 | private var currentUser by Delegates.observable(null) { _, _, new -> 110 | new?.let { userBroadcast.offer(it) } 111 | } 112 | 113 | @ExperimentalCoroutinesApi 114 | private val userBroadcast = BroadcastChannel(capacity = Channel.CONFLATED) 115 | 116 | @ExperimentalCoroutinesApi 117 | suspend fun currentUser(): CurrentUser = when (currentUser) { 118 | null -> userBroadcast.openSubscription().receive() 119 | else -> currentUser!! 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/EntryActivity.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.os.Looper 7 | import android.support.v7.app.AppCompatActivity 8 | import com.google.gson.GsonBuilder 9 | import com.pusher.chatkit.CurrentUser 10 | import com.pusher.chatkitdemo.ChatKitDemoApp.Companion.app 11 | import com.pusher.chatkitdemo.EntryActivity.State.* 12 | import com.pusher.chatkitdemo.arch.viewModel 13 | import com.pusher.chatkitdemo.navigation.NavigationEvent 14 | import com.pusher.chatkitdemo.navigation.navigationEvent 15 | import com.pusher.chatkitdemo.navigation.openInBrowser 16 | import com.pusher.chatkitdemo.navigation.openMain 17 | import elements.Error 18 | import elements.NetworkError 19 | import kotlinx.android.synthetic.main.activity_entry.* 20 | import kotlinx.android.synthetic.main.activity_entry_loaded.* 21 | import kotlinx.android.synthetic.main.include_error.* 22 | import kotlinx.android.synthetic.main.include_loading.* 23 | import kotlinx.coroutines.* 24 | import kotlinx.coroutines.channels.BroadcastChannel 25 | import kotlinx.coroutines.channels.Channel 26 | import kotlinx.coroutines.channels.consumeEach 27 | import okhttp3.* 28 | import kotlin.properties.Delegates 29 | 30 | class EntryActivity : AppCompatActivity() { 31 | 32 | private val views by lazy { arrayOf(idleLayout, loadedLayout, errorLayout) } 33 | @ExperimentalCoroutinesApi 34 | private val viewModel by lazy { viewModel() } 35 | 36 | override fun onCreate(savedInstanceState: Bundle?) { 37 | super.onCreate(savedInstanceState) 38 | setContentView(R.layout.activity_entry) 39 | } 40 | 41 | @ExperimentalCoroutinesApi 42 | @ObsoleteCoroutinesApi 43 | override fun onStart() { 44 | super.onStart() 45 | // TODO: Investigate potential leak into viewModel 46 | GlobalScope.launch { viewModel.states.consumeEach { state = it } } 47 | } 48 | 49 | @ExperimentalCoroutinesApi 50 | override fun onNewIntent(intent: Intent?) { 51 | super.onNewIntent(intent) 52 | val navigationEvent = intent?.navigationEvent 53 | when (navigationEvent) { 54 | is NavigationEvent.Entry.WithGitHubCode -> viewModel.authorize(navigationEvent.code)// Log.d("TOKEN", "token: ${navigationEvent.code}") 55 | } 56 | } 57 | 58 | @ExperimentalCoroutinesApi 59 | private var state by Delegates.observable(Idle) { _, _, state -> 60 | state.render() 61 | } 62 | 63 | sealed class State { 64 | object Idle : State() 65 | data class RequiresAuth(val authUrl: String) : State() 66 | data class UserIdReady(val userId: String) : State() 67 | data class UserReady(val currentUser: CurrentUser) : State() 68 | data class Failure(val error: Error) : State() 69 | } 70 | 71 | @ExperimentalCoroutinesApi 72 | private fun State.render() = when (this) { 73 | is Idle -> GlobalScope.launch(Dispatchers.Main) { renderIdle() } 74 | is UserIdReady -> GlobalScope.launch(Dispatchers.Main) { 75 | renderIdle() 76 | viewModel.loadUser(userId) 77 | } 78 | is UserReady -> GlobalScope.launch(Dispatchers.Main) { renderUser(currentUser) } 79 | is Failure -> GlobalScope.launch(Dispatchers.Main) { renderFailure(error) } 80 | is RequiresAuth -> GlobalScope.launch(Dispatchers.Main) { openInBrowser(authUrl) } 81 | } 82 | 83 | private fun renderIdle() { 84 | views.showOnly(idleLayout) 85 | loadingTextView.setText(R.string.logging_you_in) 86 | } 87 | 88 | private fun renderUser(user: CurrentUser) { 89 | views.showOnly(loadedLayout) 90 | userNameView.text = "Welcome ${user.name}" 91 | continueButton.setOnClickListener { 92 | openMain(user.id) 93 | } 94 | } 95 | 96 | @ExperimentalCoroutinesApi 97 | private fun renderFailure(error: Error) { 98 | views.showOnly(errorLayout) 99 | errorMessageView.text = "$error" 100 | retryButton.setOnClickListener { 101 | state = Idle 102 | viewModel.load() 103 | } 104 | } 105 | 106 | } 107 | 108 | @ExperimentalCoroutinesApi 109 | class EntryViewModel : ViewModel() { 110 | 111 | private object Github { 112 | private const val gitHubClientId = "20cdd317000f92af12fe" 113 | private const val callbackUri = "https://chatkit-demoauth-server.herokuapp.com/success?url=chatkit://auth" 114 | 115 | const val gitHubAuthUrl = "https://github.com/login/oauth/authorize" + 116 | "?client_id=$gitHubClientId" + 117 | "&scope=user:email" + 118 | "&redirect_uri=$callbackUri" 119 | } 120 | 121 | @ExperimentalCoroutinesApi 122 | private val stateBroadcast = BroadcastChannel(Channel.CONFLATED) 123 | @ExperimentalCoroutinesApi 124 | val states get() = stateBroadcast.openSubscription() 125 | 126 | private val client by lazy { OkHttpClient() } 127 | private val gson by lazy { GsonBuilder().create() } 128 | 129 | init { 130 | stateBroadcast.offer(Idle) 131 | load() 132 | } 133 | 134 | fun load() = GlobalScope.launch { 135 | app.userId.let { id -> 136 | when (id) { 137 | null -> RequiresAuth(Github.gitHubAuthUrl) 138 | else -> UserIdReady(id) 139 | }.let { stateBroadcast.send(it) } 140 | } 141 | } 142 | 143 | suspend fun loadUser(userId: String) { 144 | app.userId = userId 145 | val state = UserReady(app.currentUser()) 146 | stateBroadcast.send(state) 147 | } 148 | 149 | override fun onCleared() { 150 | stateBroadcast.close() 151 | } 152 | 153 | data class AuthResponseBody(val id: String, val token: String) 154 | data class AuthRequestBody(val code: String) 155 | 156 | fun authorize(code: String) { 157 | GlobalScope.launch { 158 | if (Looper.myLooper() == null) Looper.prepare() 159 | val requestBody = RequestBody.create(MediaType.parse("text/plain"), AuthRequestBody(code).toJson()) 160 | val request = Request.Builder().apply { 161 | url("https://chatkit-demoauth-server.herokuapp.com/auth") 162 | post(requestBody) 163 | }.build() 164 | val response = client.newCall(request).execute() 165 | val responseBody = response.body()?.fromJson() 166 | when (responseBody) { 167 | null -> stateBroadcast.offer(Failure(NetworkError("Oops! response: $response"))) 168 | else -> responseBody.let { (id, token) -> 169 | app.userId = id 170 | app.token = token 171 | stateBroadcast.send(UserIdReady(id)) 172 | } 173 | } 174 | } 175 | } 176 | 177 | private fun A.toJson(): String = 178 | gson.toJson(this) 179 | 180 | private inline fun ResponseBody.fromJson(): A = 181 | gson.fromJson(this.charStream(), A::class.java) 182 | 183 | } 184 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/LangUtil.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo 2 | 3 | /** 4 | * Simplified way to get the name of a type 5 | */ 6 | inline fun nameOf(): String = nameOf(T::class.java) 7 | 8 | fun nameOf(type: Class): String = type.simpleName 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo 2 | 3 | import android.annotation.SuppressLint 4 | import android.os.Bundle 5 | import android.support.v7.app.AppCompatActivity 6 | import android.support.v7.widget.LinearLayoutManager 7 | import android.view.View.GONE 8 | import android.view.View.VISIBLE 9 | import com.pusher.chatkit.rooms.Room 10 | import com.pusher.chatkitdemo.MainActivity.State.Loaded 11 | import com.pusher.chatkitdemo.navigation.open 12 | import com.pusher.chatkitdemo.recyclerview.dataAdapterFor 13 | import com.pusher.chatkitdemo.room.coolName 14 | import elements.Error 15 | import kotlinx.android.synthetic.main.activity_main.* 16 | import kotlinx.android.synthetic.main.item_room.* 17 | import kotlinx.coroutines.GlobalScope 18 | import kotlinx.coroutines.launch 19 | import kotlin.properties.Delegates 20 | import kotlinx.coroutines.Dispatchers 21 | import kotlinx.coroutines.ExperimentalCoroutinesApi 22 | import kotlinx.android.synthetic.main.activity_main.room_list as roomListView 23 | 24 | import android.util.Log 25 | 26 | class MainActivity : AppCompatActivity() { 27 | 28 | private val adapter = dataAdapterFor(R.layout.item_room) { room: Room -> 29 | @SuppressLint("SetTextI18n") 30 | roomNameView.text = room.coolName 31 | roomItemLayout.setOnClickListener { 32 | open(room) 33 | } 34 | } 35 | 36 | private var state by Delegates.observable(State.Idle) { _, _, state -> 37 | state.render() 38 | } 39 | 40 | @ExperimentalCoroutinesApi 41 | override fun onCreate(savedInstanceState: Bundle?) { 42 | super.onCreate(savedInstanceState) 43 | setContentView(R.layout.activity_main) 44 | setSupportActionBar(toolbar) 45 | 46 | roomListView.adapter = adapter 47 | roomListView.layoutManager = LinearLayoutManager(this) 48 | 49 | state = State.Idle 50 | 51 | 52 | 53 | GlobalScope.launch { 54 | state = Loaded(app.currentUser().rooms.toSet()) 55 | 56 | } 57 | 58 | 59 | } 60 | 61 | private fun State.render() = when (this) { 62 | is State.Idle -> GlobalScope.launch(Dispatchers.Main) { renderIdle() } 63 | is State.Loaded -> GlobalScope.launch(Dispatchers.Main) { renderLoaded(rooms) } 64 | is State.Failed -> GlobalScope.launch(Dispatchers.Main) { renderFailed(error) } 65 | } 66 | 67 | private fun renderIdle() { 68 | progress.visibility = VISIBLE 69 | roomListView.visibility = GONE 70 | errorView.visibility = GONE 71 | } 72 | 73 | private fun renderLoaded(rooms: Set) { 74 | progress.visibility = GONE 75 | roomListView.visibility = VISIBLE 76 | errorView.visibility = GONE 77 | adapter.data = rooms.filter { it.memberUserIds.size < 100 } 78 | } 79 | 80 | private fun renderFailed(error: Error) { 81 | progress.visibility = GONE 82 | roomListView.visibility = GONE 83 | errorView.visibility = VISIBLE 84 | errorView.text = "$error" 85 | } 86 | 87 | sealed class State { 88 | object Idle : State() 89 | data class Loaded(val rooms: Set) : State() 90 | data class Failed(val error: Error) : State() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/arch/viewModels.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo.arch 2 | 3 | import android.arch.lifecycle.ViewModel 4 | import android.arch.lifecycle.ViewModelProviders 5 | import android.support.v4.app.Fragment 6 | import android.support.v4.app.FragmentActivity 7 | 8 | inline fun FragmentActivity.viewModel() = 9 | ViewModelProviders.of(this).get(A::class.java) 10 | 11 | inline fun Fragment.viewModel() = 12 | ViewModelProviders.of(this).get(A::class.java) 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/navigation/navigation.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo.navigation 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.UriMatcher 7 | import android.net.Uri 8 | import android.support.customtabs.CustomTabsIntent 9 | import com.pusher.chatkit.rooms.Room 10 | import com.pusher.chatkitdemo.app 11 | 12 | private enum class Screen(val path: String, val authority: String = "chat.pusher.com") { 13 | ROOM("room/#") { 14 | override fun asNavigationEvent(uri: Uri): NavigationEvent = 15 | NavigationEvent.room(uri.lastPathSegment) 16 | }, 17 | MAIN("home") { 18 | override fun asNavigationEvent(uri: Uri): NavigationEvent = 19 | NavigationEvent.main(uri.getQueryParameter("userId")) 20 | }, 21 | ENTRY("/", "auth") { 22 | override fun asNavigationEvent(uri: Uri): NavigationEvent = with(uri) { 23 | val token = getQueryParameter("code") 24 | when (token) { 25 | null -> NavigationEvent.missingGitHubToken() 26 | else -> NavigationEvent.withGitHubToken(token) 27 | } 28 | } 29 | 30 | }; 31 | 32 | abstract fun asNavigationEvent(uri: Uri): NavigationEvent 33 | 34 | companion object { 35 | fun find(ordinal: Int): Screen? = 36 | values().getOrNull(ordinal) 37 | } 38 | } 39 | 40 | sealed class NavigationEvent { 41 | 42 | companion object { 43 | @JvmStatic 44 | fun room(roomId: String): NavigationEvent = Room(roomId) 45 | 46 | @JvmStatic 47 | fun main(userId: String): NavigationEvent = Main(userId) 48 | 49 | @JvmStatic 50 | fun withGitHubToken(token: String): NavigationEvent = Entry.WithGitHubCode(token) 51 | 52 | @JvmStatic 53 | fun missingGitHubToken(): NavigationEvent = Entry.MissingGitHubToken 54 | 55 | @JvmStatic 56 | fun missingUri(): NavigationEvent = MissingUri 57 | 58 | @JvmStatic 59 | fun unknown(uri: Uri): NavigationEvent = Unknown(uri) 60 | } 61 | 62 | data class Room(val roomId: String) : NavigationEvent() 63 | data class Main(val userId: String) : NavigationEvent() 64 | data class Unknown(val data: Uri) : NavigationEvent() 65 | object MissingUri : NavigationEvent() 66 | sealed class Entry : NavigationEvent() { 67 | data class WithGitHubCode(val code: String) : Entry() 68 | object MissingGitHubToken : Entry() 69 | } 70 | } 71 | 72 | private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply { 73 | Screen.values().forEach { screen -> with(screen) { addURI(authority, path, ordinal) } } 74 | } 75 | 76 | val Intent.navigationEvent: NavigationEvent 77 | get() = data.navigationEvent 78 | 79 | val Uri?.navigationEvent: NavigationEvent 80 | get() = when (this) { 81 | null -> NavigationEvent.missingUri() 82 | else -> Screen.find(uriMatcher.match(this))?.asNavigationEvent(this) 83 | ?: NavigationEvent.unknown(this) 84 | } 85 | 86 | fun Context.openIntent(path: String) = 87 | Intent(Intent.ACTION_VIEW, Uri.parse(path)).apply { 88 | `package` = packageName 89 | } 90 | 91 | fun Context.open(path: String) = 92 | startActivity(openIntent(path)) 93 | 94 | fun Context.openInBrowser(path: String) = 95 | CustomTabsIntent.Builder().build().launchUrl(this, Uri.parse(path)) 96 | 97 | fun Context.open(room: Room) = 98 | open("https://chat.pusher.com/room/${room.id}") 99 | 100 | fun Context.openMain(userId: String) = 101 | open("https://chat.pusher.com/home?userId=$userId") 102 | 103 | fun Activity.failNavigation(navigationEvent: NavigationEvent) { 104 | app.logger.error("Failed to load navigation: $navigationEvent") 105 | finish() 106 | } 107 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/parallel/Channels.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo.parallel 2 | 3 | import kotlinx.coroutines.ExperimentalCoroutinesApi 4 | import kotlinx.coroutines.channels.BroadcastChannel 5 | import kotlinx.coroutines.channels.Channel 6 | import kotlinx.coroutines.launch 7 | import kotlinx.coroutines.GlobalScope 8 | 9 | 10 | @ExperimentalCoroutinesApi 11 | fun lazyBroadcast(block: suspend BroadcastChannel.() -> Unit = {}) = 12 | lazy { broadcast(block) } 13 | 14 | @ExperimentalCoroutinesApi 15 | fun broadcast(block: suspend BroadcastChannel.() -> Unit = {}) = 16 | BroadcastChannel(Channel.CONFLATED).also { 17 | GlobalScope.launch { block(it) } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/parallel/LifecycleReceiverChannel.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo.parallel 2 | 3 | import android.arch.lifecycle.Lifecycle 4 | import android.arch.lifecycle.Lifecycle.Event.* 5 | import android.arch.lifecycle.LifecycleObserver 6 | import android.arch.lifecycle.LifecycleOwner 7 | import android.arch.lifecycle.OnLifecycleEvent 8 | import kotlinx.coroutines.ExperimentalCoroutinesApi 9 | import kotlinx.coroutines.channels.BroadcastChannel 10 | import kotlinx.coroutines.channels.Channel 11 | import kotlinx.coroutines.channels.ReceiveChannel 12 | import kotlinx.coroutines.channels.toChannel 13 | import kotlinx.coroutines.launch 14 | import kotlinx.coroutines.GlobalScope 15 | import kotlinx.coroutines.ObsoleteCoroutinesApi 16 | 17 | @ExperimentalCoroutinesApi 18 | fun LifecycleOwner.onLifecycle(block: () -> ReceiveChannel): ReceiveChannel = 19 | LifecycleReceiverChannel(this.lifecycle, block) 20 | 21 | private class LifecycleReceiverChannel @ExperimentalCoroutinesApi constructor( 22 | lifecycle: Lifecycle, 23 | private val block: () -> ReceiveChannel, 24 | private val broadcastChannel: BroadcastChannel = BroadcastChannel(Channel.CONFLATED), 25 | private val subscription: ReceiveChannel = broadcastChannel.openSubscription() 26 | ) : ReceiveChannel by subscription, LifecycleObserver { 27 | 28 | private var channel: ReceiveChannel? = null 29 | 30 | init { 31 | lifecycle.addObserver(this) 32 | } 33 | 34 | @ObsoleteCoroutinesApi 35 | @OnLifecycleEvent(ON_START) 36 | fun onStart() { 37 | channel?.cancel() 38 | channel = block() 39 | GlobalScope.launch { channel?.toChannel(broadcastChannel) } 40 | } 41 | 42 | @OnLifecycleEvent(ON_STOP) 43 | fun onStop() { 44 | channel?.cancel() 45 | } 46 | 47 | @OnLifecycleEvent(ON_DESTROY) 48 | fun onDestroy() { 49 | subscription.cancel() 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/preferences.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo 2 | 3 | import android.content.Context 4 | import android.content.Context.MODE_PRIVATE 5 | import kotlin.properties.ReadWriteProperty 6 | import kotlin.reflect.KProperty 7 | 8 | private const val USER_PREFERENCES_NAME = "USER_PREFERENCES_NAME" 9 | 10 | class UserPreferences(context: Context) { 11 | 12 | private val preferences = SharedPreferencesExtension(context.applicationContext, USER_PREFERENCES_NAME) 13 | 14 | var userId by preferences.string("userId") 15 | var token by preferences.string("token") 16 | 17 | } 18 | 19 | private class SharedPreferencesExtension(context: Context, name: String) { 20 | 21 | private val preferences = context.getSharedPreferences(name, MODE_PRIVATE) 22 | 23 | fun string(key: String) = object : ReadWriteProperty { 24 | override operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) = 25 | preferences.edit().putString(key, value).apply() 26 | 27 | override operator fun getValue(thisRef: Any?, property: KProperty<*>): String? = 28 | preferences.getString(key, null) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/recyclerview/DataAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo.recyclerview 2 | 3 | import android.support.annotation.LayoutRes 4 | import android.support.v7.widget.RecyclerView 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import kotlinx.android.extensions.LayoutContainer 9 | import kotlin.properties.Delegates 10 | 11 | fun dataAdapterFor( 12 | layoutRes: Int, 13 | onBind: LayoutContainer.(A) -> Unit 14 | ): DataAdapter = SimpleDataAdapter(layoutRes, onBind) 15 | 16 | fun dataAdapterFor( 17 | block: DataAdapterContext.() -> Unit 18 | ): DataAdapter = MultiDataAdapter( 19 | DataAdapterContextWithMap().apply(block).adapterMap 20 | ) 21 | 22 | sealed class DataAdapter : RecyclerView.Adapter>() { 23 | 24 | var data: List by Delegates.observable(emptyList()) { _, _, _ -> 25 | notifyDataSetChanged() 26 | } 27 | 28 | @JvmOverloads 29 | fun insert(item: A, index: Int = 0) { 30 | data = data.subList(0, index) + item + data.subList(index, data.size) 31 | } 32 | 33 | operator fun plusAssign(item: A) = 34 | insert(item) 35 | } 36 | 37 | sealed class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 38 | abstract fun bind(data: A) 39 | } 40 | 41 | typealias ViewHolderFactory = (A) -> DataViewHolder 42 | 43 | /** 44 | * Generalised [RecyclerView.Adapter] for lists of simple items using android extensions for the binding. 45 | */ 46 | private class SimpleDataAdapter( 47 | @LayoutRes private val layoutRes: Int, 48 | private val onBind: LayoutContainer.(A) -> Unit 49 | ) : DataAdapter() { 50 | 51 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = 52 | BindDataViewHolder(parent, layoutRes, onBind) 53 | 54 | override fun getItemCount(): Int = 55 | data.size 56 | 57 | override fun onBindViewHolder(holder: DataViewHolder, position: Int) = 58 | holder.bind(data[position]) 59 | 60 | } 61 | 62 | private class BindDataViewHolder( 63 | parent: ViewGroup, 64 | @LayoutRes layoutRes: Int, 65 | private val onBind: LayoutContainer.(A) -> Unit 66 | ) : DataViewHolder( 67 | LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) 68 | ), LayoutContainer { 69 | 70 | override val containerView: View = itemView 71 | 72 | override fun bind(data: A) { 73 | onBind(data) 74 | } 75 | 76 | } 77 | 78 | private class MultiDataAdapter( 79 | private val adapterMap: Map<(A) -> Boolean, IndexedAdapter> 80 | ) : DataAdapter() { 81 | 82 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataViewHolder = 83 | adapterMap.values.first { it.viewType == viewType }.adapter.invoke(parent) 84 | 85 | override fun getItemCount(): Int = 86 | data.size 87 | 88 | override fun onBindViewHolder(holder: DataViewHolder, position: Int) = 89 | holder.bind(data[position]) 90 | 91 | override fun getItemViewType(position: Int): Int = 92 | adapterMap.entries 93 | .first { (accepts, _) -> accepts(data[position]) }.value.viewType 94 | 95 | } 96 | 97 | private data class IndexedAdapter(val viewType: Int, val adapter: (parent: ViewGroup) -> DataViewHolder) 98 | 99 | sealed class DataAdapterContext { 100 | abstract fun on(accept: (A) -> Boolean, @LayoutRes layoutRes: Int, onBind: LayoutContainer.(A) -> Unit) 101 | 102 | inline fun on(@LayoutRes layoutRes: Int, crossinline onBind: LayoutContainer.(B) -> Unit) = 103 | on({ it is B }, layoutRes) { onBind(it as B) } 104 | } 105 | 106 | private class DataAdapterContextWithMap : DataAdapterContext() { 107 | 108 | val adapterMap = mutableMapOf<(A) -> Boolean, IndexedAdapter>() 109 | 110 | override fun on(accept: (A) -> Boolean, @LayoutRes layoutRes: Int, onBind: LayoutContainer.(A) -> Unit) { 111 | adapterMap += accept to IndexedAdapter(adapterMap.size) { parent -> BindDataViewHolder(parent, layoutRes, onBind) } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/room/RoomActivity.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo.room 2 | 3 | import android.os.Bundle 4 | import android.support.v7.app.AppCompatActivity 5 | import com.pusher.chatkitdemo.R 6 | import com.pusher.chatkitdemo.navigation.NavigationEvent 7 | import com.pusher.chatkitdemo.navigation.failNavigation 8 | import com.pusher.chatkitdemo.navigation.navigationEvent 9 | import kotlinx.android.synthetic.main.activity_room.* 10 | import android.net.Uri.parse as parseUri 11 | 12 | class RoomActivity : AppCompatActivity() { 13 | 14 | override fun onCreate(savedInstanceState: Bundle?) { 15 | super.onCreate(savedInstanceState) 16 | setContentView(R.layout.activity_room) 17 | 18 | setSupportActionBar(toolbar) 19 | supportActionBar?.setDisplayShowHomeEnabled(true) 20 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 21 | 22 | val event = intent.navigationEvent 23 | when (event) { 24 | is NavigationEvent.Room -> (roomFragment as RoomFragment).bind(event.roomId) 25 | else -> failNavigation(event) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/room/RoomFragment.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo.room 2 | 3 | import android.arch.lifecycle.Lifecycle.State.STARTED 4 | import android.arch.lifecycle.LifecycleOwner 5 | import android.os.Bundle 6 | import android.os.Looper 7 | import android.support.annotation.UiThread 8 | import android.support.v4.app.Fragment 9 | import android.support.v7.widget.LinearLayoutManager 10 | import android.util.Log 11 | import android.view.LayoutInflater 12 | import android.view.View 13 | import android.view.ViewGroup 14 | import com.pusher.chatkit.messages.multipart.NewPart 15 | import com.pusher.chatkit.messages.multipart.Payload 16 | import com.pusher.chatkit.messages.multipart.Payload.Inline 17 | import com.pusher.chatkit.rooms.Room 18 | import com.pusher.chatkit.rooms.RoomListeners 19 | import com.pusher.chatkitdemo.ChatKitDemoApp.Companion.app 20 | import com.pusher.chatkitdemo.R 21 | import com.pusher.chatkitdemo.recyclerview.dataAdapterFor 22 | import com.pusher.chatkitdemo.room.RoomState.* 23 | import com.pusher.chatkitdemo.showOnly 24 | import com.pusher.util.Result 25 | import elements.Subscription 26 | import kotlinx.android.synthetic.main.fragment_room.* 27 | import kotlinx.android.synthetic.main.fragment_room_loaded.* 28 | import kotlinx.android.synthetic.main.include_error.* 29 | import kotlinx.android.synthetic.main.item_message.* 30 | import kotlinx.coroutines.* 31 | import kotlin.properties.Delegates 32 | 33 | typealias PusherError = elements.Error 34 | 35 | class RoomFragment : Fragment() { 36 | 37 | private val views by lazy { arrayOf(idleLayout, loadedLayout, errorLayout) } 38 | 39 | private var state by Delegates.observable(RoomState.Initial) { _, _, new -> 40 | new.render() 41 | } 42 | 43 | private val adapter = dataAdapterFor { 44 | on(R.layout.item_message) { (details) -> 45 | userNameView.text = details.userName 46 | messageView.text = details.message 47 | } 48 | on(R.layout.item_message_pending) { (details) -> 49 | userNameView.text = details.userName 50 | messageView.text = details.message 51 | } 52 | on(R.layout.item_message_pending) { (details, _) -> 53 | userNameView.text = details.userName 54 | messageView.text = details.message 55 | } 56 | } 57 | 58 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = 59 | inflater.inflate(R.layout.fragment_room, container, false) 60 | 61 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 62 | super.onViewCreated(view, savedInstanceState) 63 | 64 | messageList.adapter = adapter 65 | messageList.layoutManager = LinearLayoutManager(activity).apply { 66 | reverseLayout = false 67 | stackFromEnd = false 68 | isSmoothScrollbarEnabled = true 69 | } 70 | sendButton.setOnClickListener { 71 | messageInput.text.takeIf { it.isNotBlank() }?.let { text -> 72 | state.let { it as? Ready }?.let { it.room.id }?.let { roomId -> 73 | sendMessage(roomId, text.toString()) 74 | messageInput.text.clear() 75 | } 76 | } 77 | } 78 | } 79 | 80 | override fun onDestroyView() { 81 | super.onDestroyView() 82 | 83 | state.let { it as? Ready }?.sub?.unsubscribe() 84 | } 85 | 86 | @ExperimentalCoroutinesApi 87 | private fun sendMessage(roomId: String, text: String) = GlobalScope.launch { 88 | app.currentUser().apply { 89 | val item = Item.Pending(Item.Details(name ?: id, text)) 90 | addItem(item) 91 | 92 | sendMultipartMessage( 93 | roomId = roomId, 94 | parts = listOf(NewPart.Inline(text, "text/plain")), 95 | callback = { result -> 96 | when (result) { 97 | is Result.Success -> { 98 | removeItem(item) 99 | } 100 | is Result.Failure -> { 101 | removeItem(item) 102 | } 103 | } 104 | } 105 | ) 106 | } 107 | } 108 | 109 | @ExperimentalCoroutinesApi 110 | fun bind(roomId: String) = GlobalScope.launch { 111 | // if (Looper.myLooper() == null) Looper.prepare() // Old version of the SDK uses a handle and breaks 112 | 113 | with(app.currentUser()) { 114 | val room = rooms.find { it.id == roomId } 115 | var messagecontent: String = "" 116 | when (room) { 117 | null -> renderFailed(Error("Room not found")) 118 | else -> subscribeToRoomMultipart( 119 | room = room, 120 | messageLimit = 20, 121 | listeners = RoomListeners( 122 | onErrorOccurred = { error -> 123 | state = Failed(error) 124 | }, 125 | onMultipartMessage = { message -> 126 | message.parts.forEach { part -> 127 | val payload = part.payload 128 | when (payload) { 129 | is Inline -> { 130 | messagecontent = payload.content 131 | } 132 | is Payload.Url -> { 133 | } 134 | is Payload.Attachment -> { 135 | } 136 | else -> { 137 | } 138 | } 139 | } 140 | Log.d("Slackclone", "Message added" + message.sender?.name) 141 | 142 | addItem( 143 | Item.Loaded( 144 | Item.Details( 145 | userName = message.sender?.name 146 | ?: "???", 147 | message = messagecontent ?: "----" 148 | ) 149 | ) 150 | ) 151 | 152 | 153 | } 154 | ), 155 | 156 | 157 | callback = { subscription -> 158 | Log.d("Slackclone", "Room Ready") 159 | state = Ready(room, subscription, adapter.data) 160 | } 161 | ) 162 | } 163 | } 164 | } 165 | 166 | private fun addItem(item: Item) = launchOnUi { 167 | adapter.data = adapter.data + item 168 | messageList.scrollToPosition(adapter.itemCount - 1) 169 | } 170 | 171 | 172 | private fun removeItem(item: Item) = launchOnUi { 173 | adapter.data = adapter.data - item 174 | } 175 | 176 | 177 | private fun RoomState.render(): Job = when (this) { 178 | is Initial -> renderIdle() 179 | is Ready -> renderLoadedCompletely(room, items) 180 | is Failed -> renderFailed(Error("$error")) 181 | } 182 | 183 | @UiThread 184 | private fun renderIdle() = launchOnUi { 185 | views.showOnly(idleLayout) 186 | adapter.data = emptyList() 187 | } 188 | 189 | private fun renderLoadedCompletely(room: Room, messages: List) = launchOnUi { 190 | views.showOnly(loadedLayout) 191 | activity?.title = room.coolName 192 | adapter.data = messages 193 | } 194 | 195 | private fun renderFailed(error: Error) = launchOnUi { 196 | views.showOnly(errorLayout) 197 | errorMessageView.text = error.message 198 | retryButton.visibility = View.GONE // TODO: Retry button 199 | } 200 | } 201 | 202 | private fun LifecycleOwner.launchOnUi(block: suspend CoroutineScope.() -> Unit) = when { 203 | lifecycle.currentState > STARTED -> GlobalScope.launch(context = Dispatchers.Main, block = block) 204 | else -> GlobalScope.launch { Log.d("Slackclone", "Unexpected lifecycle state: ${lifecycle.currentState}") } 205 | } 206 | 207 | sealed class RoomState { 208 | 209 | object Initial : RoomState() 210 | data class Ready(val room: Room, val sub: Subscription, val items: List) : RoomState() 211 | data class Failed(val error: PusherError) : RoomState() 212 | 213 | sealed class Item { 214 | abstract val details: Details 215 | 216 | data class Loaded(override val details: Details) : Item() 217 | data class Pending(override val details: Details) : Item() 218 | data class Failed(override val details: Details, val error: PusherError) : Item() 219 | 220 | data class Details(val userName: String, val message: String) 221 | } 222 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/room/rooms.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo.room 2 | 3 | import com.pusher.chatkit.rooms.Room 4 | 5 | val Room.coolName 6 | get() = "#$name" -------------------------------------------------------------------------------- /app/src/main/kotlin/com/pusher/chatkitdemo/views.kt: -------------------------------------------------------------------------------- 1 | package com.pusher.chatkitdemo 2 | 3 | import android.view.View 4 | import android.view.View.GONE 5 | import android.view.View.VISIBLE 6 | 7 | fun Array.showOnly(view: View) = forEach { 8 | when (it) { 9 | view -> it.visibility = VISIBLE 10 | else -> it.visibility = GONE 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_entry.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 12 | 13 | 17 | 18 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_entry_loaded.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 20 | 21 |