├── .DS_Store ├── .gitignore ├── .idea ├── caches │ └── build_file_checksums.ser ├── checkstyle-idea.xml ├── codeStyles │ └── Project.xml ├── compiler.xml ├── copyright │ └── profiles_settings.xml ├── dbnavigator.xml ├── misc.xml ├── modules.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── frangsierra │ │ └── kotlinfirechat │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── frangsierra │ │ │ └── kotlinfirechat │ │ │ ├── chat │ │ │ ├── controller │ │ │ │ └── MessagesController.kt │ │ │ ├── model │ │ │ │ └── Models.kt │ │ │ └── store │ │ │ │ ├── ChatActions.kt │ │ │ │ ├── ChatState.kt │ │ │ │ └── ChatStore.kt │ │ │ ├── core │ │ │ ├── SplashActivity.kt │ │ │ ├── dagger │ │ │ │ ├── AppComponent.kt │ │ │ │ ├── DaggerUtils.kt │ │ │ │ └── Scopes.kt │ │ │ ├── errors │ │ │ │ ├── CrashlyticsHandler.kt │ │ │ │ └── ErrorHandlingModule.kt │ │ │ ├── firebase │ │ │ │ ├── FirebaseConstants.kt │ │ │ │ ├── FirebaseModels.kt │ │ │ │ ├── FirebaseModule.kt │ │ │ │ └── FirebaseRefs.kt │ │ │ ├── flux │ │ │ │ ├── App.kt │ │ │ │ ├── Async.kt │ │ │ │ ├── CustomLoggerInterceptor.kt │ │ │ │ ├── FluxActivity.kt │ │ │ │ └── FluxFragment.kt │ │ │ └── service │ │ │ │ └── DataUploadService.kt │ │ │ ├── home │ │ │ ├── HomeActivity.kt │ │ │ └── MessageAdapter.kt │ │ │ ├── profile │ │ │ ├── controller │ │ │ │ └── ProfileController.kt │ │ │ ├── model │ │ │ │ └── Models.kt │ │ │ └── store │ │ │ │ ├── ProfileActions.kt │ │ │ │ ├── ProfileState.kt │ │ │ │ └── ProfileStore.kt │ │ │ ├── session │ │ │ ├── CreateAccountActivity.kt │ │ │ ├── LoginActivity.kt │ │ │ ├── VerifyEmailActivity.kt │ │ │ ├── controller │ │ │ │ └── SessionController.kt │ │ │ ├── model │ │ │ │ ├── LoginProvider.kt │ │ │ │ ├── SessionExceptions.kt │ │ │ │ └── User.kt │ │ │ └── store │ │ │ │ ├── SessionActions.kt │ │ │ │ ├── SessionState.kt │ │ │ │ └── SessionStore.kt │ │ │ └── util │ │ │ ├── AlertDialogExtensions.kt │ │ │ ├── AndroidUtils.kt │ │ │ ├── GlideExtensions.kt │ │ │ ├── GoogleLoginCallback.kt │ │ │ ├── GoogleSignInApiUtils.kt │ │ │ ├── ImageUtils.kt │ │ │ ├── Prefs.kt │ │ │ ├── ProgressDialogFragment.kt │ │ │ ├── RequestState.kt │ │ │ ├── RxUtils.kt │ │ │ └── UtilExtensions.kt │ └── res │ │ ├── drawable │ │ ├── firebase.png │ │ └── firebase_icon.png │ │ ├── layout │ │ ├── create_account_activity.xml │ │ ├── home_activity.xml │ │ ├── item_message.xml │ │ ├── login_activity.xml │ │ ├── progress_dialog_layout.xml │ │ └── verification_email_activity.xml │ │ ├── menu │ │ └── chat_menu.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 │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ └── provider_paths.xml │ └── test │ └── java │ └── frangsierra │ └── kotlinfirechat │ └── ExampleUnitTest.java ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrangSierra/KotlinFirechat/52ea49bb5c07c6689ad5d163e0eba7caad468674/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | # Proguard folder generated by Eclipse 24 | proguard/ 25 | 26 | # Log Files 27 | *.log 28 | 29 | # Android Studio Navigation editor temp files 30 | .navigation/ 31 | 32 | # Android Studio captures folder 33 | captures/ 34 | 35 | # Intellij 36 | *.iml 37 | .idea/workspace.xml 38 | .idea/tasks.xml 39 | .idea/gradle.xml 40 | .idea/dictionaries 41 | .idea/libraries 42 | 43 | # Keystore files 44 | *.jks 45 | 46 | # External native build folder generated in Android Studio 2.2 and later 47 | .externalNativeBuild 48 | 49 | # Google Services (e.g. APIs or Firebase) 50 | google-services.json 51 | 52 | # Freeline 53 | freeline.py 54 | freeline/ 55 | freeline_project_description.json 56 | -------------------------------------------------------------------------------- /.idea/caches/build_file_checksums.ser: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrangSierra/KotlinFirechat/52ea49bb5c07c6689ad5d163e0eba7caad468674/.idea/caches/build_file_checksums.ser -------------------------------------------------------------------------------- /.idea/checkstyle-idea.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/dbnavigator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 1.7 38 | 39 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KotlinFirechat 2 | This project is a basic chat application using Flux Architecture together with [Rx Java 2.0](https://github.com/ReactiveX/RxJava/tree/2.x) and [Dagger2](https://google.github.io/dagger/). 3 | 4 | ## Firebase Android Series 5 | The repository works as a sample for Firebase Android Series. A series of articles published on medium that shows how to build an Android application with Kotlin and Firebase from scratch. Each chapter of the series comes together with a branch of this repository: 6 | * [Introduction](https://proandroiddev.com/firebase-android-series-learning-firebase-from-zero-to-hero-3bacbdf8e048) 7 | * [Firebase Authentication](https://proandroiddev.com/firebase-android-series-authentication-74f209c59738) -> [Sample](https://github.com/FrangSierra/KotlinFirechat/tree/01-Auth) 8 | * [Firebase Firestore](https://proandroiddev.com/firebase-android-series-firestore-17e8951c574e) -> [Sample](https://github.com/FrangSierra/KotlinFirechat/tree/02-Database) 9 | * [Crashlytics](https://proandroiddev.com/firebase-android-series-crashlytics-29de3f507d6) -> [Sample](https://github.com/FrangSierra/KotlinFirechat/tree/03-Crashlytics) 10 | * **Firebase Test Lab** -> Coming soon 11 | * **Firebase Storage** -> Coming soon 12 | * **Firebase Cloud Functions** -> Coming soon 13 | * **Firebase Cloud Messaging** -> Coming soon 14 | * **Firebase Dynamic Links** -> Coming soon 15 | * **Firebase Performance** -> Coming soon 16 | * **Firebase Analytics** -> Coming soon 17 | 18 | 19 | ## Flux Architecure 20 | Flux is an Architecture which works pretty well with Firebase(It does aswell with Redux), it allows you to keep all the data in cache in a really easy mode, together with data persistence of Firebase it becomes a really strongh way of develop applications. 21 | 22 | ![alt tag](https://raw.githubusercontent.com/lgvalle/lgvalle.github.io/master/public/images/flux-graph-complete.png) 23 | Graph by [Luis G. Valle](http://lgvalle.xyz/) 24 | 25 | ## Further Reading: 26 | * [Facebook Flux Overview](https://facebook.github.io/flux/docs/overview.html) 27 | * [Flux & Android](http://armueller.github.io/android/2015/03/29/flux-and-android.html) 28 | * [Testing Flux Applications](https://facebook.github.io/flux/docs/testing-flux-applications.html#content) 29 | * [Flux Step by Step](http://blogs.atlassian.com/2014/08/flux-architecture-step-by-step/) 30 | * [What's different in Rx 2.0](https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0) 31 | * [Dependency Injection with Dagger 2](https://guides.codepath.com/android/Dependency-Injection-with-Dagger-2) 32 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea/ 4 | /local.properties 5 | /.idea/workspace.xml 6 | /.idea/libraries 7 | .DS_Store 8 | /build 9 | /captures 10 | .externalNativeBuild 11 | 12 | 13 | # Created by https://www.gitignore.io/api/java,gradle,intellij,osx,windows,linux 14 | 15 | ### Intellij ### 16 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 17 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 18 | 19 | # User-specific stuff: 20 | .idea/**/workspace.xml 21 | .idea/**/tasks.xml 22 | 23 | # Sensitive or high-churn files: 24 | .idea/**/dataSources/ 25 | .idea/**/dataSources.ids 26 | .idea/**/dataSources.xml 27 | .idea/**/dataSources.local.xml 28 | .idea/**/sqlDataSources.xml 29 | .idea/**/dynamic.xml 30 | .idea/**/uiDesigner.xml 31 | 32 | # Gradle: 33 | .idea/**/gradle.xml 34 | .idea/**/libraries 35 | 36 | # Mongo Explorer plugin: 37 | .idea/**/mongoSettings.xml 38 | 39 | ## File-based project format: 40 | *.iws 41 | 42 | ## Plugin-specific files: 43 | 44 | # IntelliJ 45 | /out/ 46 | 47 | # mpeltonen/sbt-idea plugin 48 | .idea_modules/ 49 | 50 | # JIRA plugin 51 | atlassian-ide-plugin.xml 52 | 53 | # Crashlytics plugin (for Android Studio and IntelliJ) 54 | com_crashlytics_export_strings.xml 55 | crashlytics.properties 56 | crashlytics-build.properties 57 | fabric.properties 58 | 59 | ### Intellij Patch ### 60 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 61 | 62 | # *.iml 63 | # modules.xml 64 | # .idea/misc.xml 65 | # *.ipr 66 | 67 | ### Java ### 68 | # Compiled class file 69 | *.class 70 | 71 | # Log file 72 | *.message 73 | 74 | # BlueJ files 75 | *.ctxt 76 | 77 | # Mobile Tools for Java (J2ME) 78 | .mtj.tmp/ 79 | 80 | # Package Files # 81 | *.jar 82 | *.war 83 | *.ear 84 | *.zip 85 | *.tar.gz 86 | *.rar 87 | 88 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 89 | hs_err_pid* 90 | 91 | ### Linux ### 92 | *~ 93 | 94 | # temporary files which can be created if a process still has a handle open of a deleted file 95 | .fuse_hidden* 96 | 97 | # KDE directory preferences 98 | .directory 99 | 100 | # Linux trash folder which might appear on any partition or disk 101 | .Trash-* 102 | 103 | # .nfs files are created when an open file is removed but is still being accessed 104 | .nfs* 105 | 106 | ### OSX ### 107 | *.DS_Store 108 | .AppleDouble 109 | .LSOverride 110 | 111 | # Icon must end with two \r 112 | Icon 113 | 114 | 115 | # Thumbnails 116 | ._* 117 | 118 | # Files that might appear in the root of a volume 119 | .DocumentRevisions-V100 120 | .fseventsd 121 | .Spotlight-V100 122 | .TemporaryItems 123 | .Trashes 124 | .VolumeIcon.icns 125 | .com.apple.timemachine.donotpresent 126 | 127 | # Directories potentially created on remote AFP share 128 | .AppleDB 129 | .AppleDesktop 130 | Network Trash Folder 131 | Temporary Items 132 | .apdisk 133 | 134 | ### Windows ### 135 | # Windows thumbnail cache files 136 | Thumbs.db 137 | ehthumbs.db 138 | ehthumbs_vista.db 139 | 140 | # Folder config file 141 | Desktop.ini 142 | 143 | # Recycle Bin used on file shares 144 | $RECYCLE.BIN/ 145 | 146 | # Windows Installer files 147 | *.cab 148 | *.msi 149 | *.msm 150 | *.msp 151 | 152 | # Windows shortcuts 153 | *.lnk 154 | 155 | ### Gradle ### 156 | .gradle 157 | /build/ 158 | 159 | # Ignore Gradle GUI config 160 | gradle-app.setting 161 | 162 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 163 | !gradle-wrapper.jar 164 | 165 | # Cache of project 166 | .gradletasknamecache 167 | 168 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 169 | # gradle/wrapper/gradle-wrapper.properties 170 | 171 | # End of https://www.gitignore.io/api/java,gradle,intellij,osx,windows,linux -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | gson_version = "2.8.2" 4 | dagger_version = "2.15" 5 | rx_version = "2.1.8" 6 | glide_version = "4.7.1" 7 | rx_firebase_version = "1.1.3" 8 | support_version = "27.1.1" 9 | rx_android_version = "2.0.2" 10 | glide_version = "4.7.1" 11 | target_sdk_version = 27 12 | } 13 | } 14 | 15 | apply plugin: 'com.android.application' 16 | apply plugin: 'io.fabric' 17 | apply plugin: 'kotlin-android' 18 | apply plugin: 'kotlin-kapt' 19 | apply plugin: 'kotlin-android-extensions' 20 | 21 | android { 22 | compileSdkVersion target_sdk_version 23 | buildToolsVersion "27.0.3" 24 | defaultConfig { 25 | applicationId "frangsierra.kotlinfirechat" 26 | minSdkVersion 21 27 | targetSdkVersion target_sdk_version 28 | versionCode 1 29 | versionName "1.0" 30 | multiDexEnabled true 31 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 32 | } 33 | 34 | packagingOptions { 35 | exclude 'META-INF/main.kotlin_module' 36 | } 37 | 38 | buildTypes { 39 | release { 40 | minifyEnabled false 41 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 42 | } 43 | } 44 | sourceSets { 45 | main.java.srcDirs += 'src/main/kotlin' 46 | } 47 | 48 | compileOptions { 49 | sourceCompatibility "1.7" 50 | targetCompatibility "1.7" 51 | } 52 | 53 | lintOptions { 54 | abortOnError false 55 | } 56 | 57 | configurations.all { 58 | //This is library is included with two different versions 59 | resolutionStrategy.force 'com.google.code.findbugs:jsr305:3.0.1' 60 | } 61 | } 62 | 63 | dependencies { 64 | implementation fileTree(dir: 'libs', include: ['*.jar']) 65 | //Kotlin 66 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 67 | implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" 68 | 69 | //Support 70 | implementation "com.android.support:appcompat-v7:$support_version" 71 | implementation "com.android.support:design:$support_version" 72 | implementation 'com.android.support.constraint:constraint-layout:1.1.2' 73 | 74 | //Firebase 75 | implementation "com.google.firebase:firebase-firestore:17.0.4" 76 | implementation 'com.google.firebase:firebase-storage:16.0.1' 77 | implementation "com.google.firebase:firebase-auth:16.0.2" 78 | implementation "com.google.firebase:firebase-core:16.0.1" 79 | implementation "com.google.android.gms:play-services-auth:15.0.1" 80 | implementation 'com.crashlytics.sdk.android:crashlytics:2.9.4' 81 | implementation "com.firebase:firebase-jobdispatcher:0.8.5" 82 | 83 | //Mini 84 | implementation 'com.github.pabloogc:Mini:1.0.5' 85 | annotationProcessor 'com.github.pabloogc.Mini:mini-processor:1.0.5' 86 | 87 | //Glide 88 | implementation "com.github.bumptech.glide:glide:$glide_version" 89 | kapt "com.github.bumptech.glide:compiler:$glide_version" 90 | 91 | //Dagger 92 | implementation "com.google.dagger:dagger-android:$dagger_version" 93 | implementation "com.google.dagger:dagger-android-support:$dagger_version" 94 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 95 | kapt "com.google.dagger:dagger-android-processor:$dagger_version" 96 | 97 | //Rx 98 | implementation "io.reactivex.rxjava2:rxjava:$rx_version" 99 | implementation "io.reactivex.rxjava2:rxandroid:$rx_android_version" 100 | implementation 'com.tbruyelle.rxpermissions2:rxpermissions:0.9.5@aar' 101 | 102 | //Test 103 | testImplementation 'junit:junit:4.12' 104 | testImplementation 'com.natpryce:hamkrest:1.4.2.0' 105 | testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version" 106 | testImplementation 'org.junit.platform:junit-platform-runner:1.0.1' 107 | } 108 | 109 | repositories { 110 | mavenCentral() 111 | maven { url "http://dl.bintray.com/jetbrains/spek" } 112 | } 113 | 114 | apply plugin: 'com.google.gms.google-services' -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in C:\Users\Durdin\AppData\Local\Android\sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | 19 | # Uncomment this to preserve the line number information for 20 | # debugging stack traces. 21 | #-keepattributes SourceFile,LineNumberTable 22 | 23 | # If you keep the line number information, uncomment this to 24 | # hide the original source file name. 25 | #-renamesourcefileattribute SourceFile 26 | -------------------------------------------------------------------------------- /app/src/androidTest/java/frangsierra/kotlinfirechat/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat; 2 | 3 | import android.content.Context; 4 | import android.support.test.InstrumentationRegistry; 5 | import android.support.test.runner.AndroidJUnit4; 6 | 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | 10 | import static org.junit.Assert.*; 11 | 12 | /** 13 | * Instrumentation test, which will execute on an Android device. 14 | * 15 | * @see Testing documentation 16 | */ 17 | @RunWith(AndroidJUnit4.class) 18 | public class ExampleInstrumentedTest { 19 | @Test 20 | public void useAppContext() throws Exception { 21 | // Context of the app under test. 22 | Context appContext = InstrumentationRegistry.getTargetContext(); 23 | 24 | assertEquals("frangsierra.kotlinfirechat", appContext.getPackageName()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 30 | 33 | 36 | 39 | 40 | 45 | 48 | 49 | 50 | 51 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/chat/controller/MessagesController.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.chat.controller 2 | 3 | import android.net.Uri 4 | import com.firebase.jobdispatcher.FirebaseJobDispatcher 5 | import com.google.android.gms.tasks.Tasks 6 | import com.google.firebase.firestore.FirebaseFirestore 7 | import com.google.firebase.firestore.QuerySnapshot 8 | import frangsierra.kotlinfirechat.chat.store.ListeningChatMessagesCompleteAction 9 | import frangsierra.kotlinfirechat.chat.store.MessagesLoadedAction 10 | import frangsierra.kotlinfirechat.chat.store.SendMessageCompleteAction 11 | import frangsierra.kotlinfirechat.core.dagger.AppScope 12 | import frangsierra.kotlinfirechat.core.firebase.* 13 | import frangsierra.kotlinfirechat.core.firebase.FirebaseConstants.TOTAL_MESSAGES 14 | import frangsierra.kotlinfirechat.core.flux.doAsync 15 | import frangsierra.kotlinfirechat.core.service.buildUploadJob 16 | import frangsierra.kotlinfirechat.profile.model.PublicProfile 17 | import frangsierra.kotlinfirechat.util.taskFailure 18 | import frangsierra.kotlinfirechat.util.taskSuccess 19 | import io.reactivex.BackpressureStrategy 20 | import io.reactivex.Flowable 21 | import io.reactivex.schedulers.Schedulers 22 | import mini.Dispatcher 23 | import javax.inject.Inject 24 | 25 | interface ChatController { 26 | fun startListeningMessages() 27 | fun sendMessage(message: String, imageUri: Uri?, publicProfile: PublicProfile) 28 | } 29 | 30 | @AppScope 31 | class ChatControllerImpl @Inject constructor(private val firestore: FirebaseFirestore, 32 | private val dispatcher: Dispatcher, 33 | private val firebaseJobDispatcher: FirebaseJobDispatcher) : ChatController { 34 | override fun startListeningMessages() { 35 | val disposable = listenMessagesFlowable() 36 | .map { it.documents.map { it.toMessage() } } 37 | .subscribeOn(Schedulers.io()) 38 | .subscribe { dispatcher.dispatchOnUi(MessagesLoadedAction(it)) } 39 | dispatcher.dispatchOnUi(ListeningChatMessagesCompleteAction(disposable)) 40 | } 41 | 42 | override fun sendMessage(message: String, imageUri: Uri?, publicProfile: PublicProfile) { 43 | doAsync { 44 | val newId = firestore.messages().document().id 45 | val firebaseMessage = FirebaseMessage(publicProfile.userData.toFirebaseUserData(), message) 46 | 47 | if (imageUri != null) { 48 | val uploadJob = buildUploadJob(imageUri.toString(), publicProfile.userData.uid, newId, firebaseJobDispatcher.newJobBuilder()) 49 | firebaseJobDispatcher.mustSchedule(uploadJob) 50 | } 51 | 52 | try { 53 | val batch = firestore.batch() 54 | batch.set(firestore.messageDoc(newId), firebaseMessage) 55 | batch.update(firestore.publicProfileDoc(publicProfile.userData.uid), 56 | mapOf(TOTAL_MESSAGES to publicProfile.totalMessages.plus(1))) 57 | Tasks.await(batch.commit()) 58 | 59 | dispatcher.dispatchOnUi(SendMessageCompleteAction(firebaseMessage.toMessage(newId), taskSuccess())) 60 | } catch (e: Throwable) { 61 | dispatcher.dispatchOnUi(SendMessageCompleteAction(null, taskFailure(e))) 62 | } 63 | } 64 | } 65 | 66 | private fun listenMessagesFlowable(): Flowable { 67 | return Flowable.create({ emitter -> 68 | val registration = firestore.messages().addSnapshotListener { documentSnapshot, e -> 69 | if (e != null && !emitter.isCancelled) { 70 | emitter.onError(e) 71 | } else if (documentSnapshot != null) { 72 | emitter.onNext(documentSnapshot) 73 | } 74 | } 75 | emitter.setCancellable { registration.remove() } 76 | }, BackpressureStrategy.BUFFER) 77 | } 78 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/chat/model/Models.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.chat.model 2 | 3 | import com.google.firebase.firestore.ServerTimestamp 4 | import frangsierra.kotlinfirechat.profile.model.UserData 5 | import java.util.* 6 | 7 | data class Message(val uid: String = "", 8 | val author: UserData, 9 | val message: String, 10 | val attachedImageUrl: String?, 11 | @ServerTimestamp val timestamp: Date) -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/chat/store/ChatActions.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.chat.store 2 | 3 | import android.net.Uri 4 | import frangsierra.kotlinfirechat.chat.model.Message 5 | import frangsierra.kotlinfirechat.util.Task 6 | import io.reactivex.disposables.Disposable 7 | import mini.Action 8 | 9 | class StartListeningChatMessagesAction : Action 10 | 11 | data class ListeningChatMessagesCompleteAction(val disposable: Disposable) : Action 12 | 13 | class MessagesLoadedAction(val messages: List) : Action 14 | 15 | class StopListeningChatMessagesAction : Action 16 | 17 | data class SendMessageAction(val message: String, val attachedImageUri : Uri? = null) : Action 18 | 19 | data class SendMessageCompleteAction(val message : Message?, val task: Task) : Action -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/chat/store/ChatState.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.chat.store 2 | 3 | import frangsierra.kotlinfirechat.chat.model.Message 4 | import frangsierra.kotlinfirechat.util.Task 5 | import io.reactivex.disposables.CompositeDisposable 6 | 7 | data class ChatState(val messages: Map = emptyMap(), 8 | val sendMessageTask : Task = Task(), 9 | val disposables: CompositeDisposable = CompositeDisposable()) -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/chat/store/ChatStore.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.chat.store 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.multibindings.ClassKey 6 | import dagger.multibindings.IntoMap 7 | import frangsierra.kotlinfirechat.chat.controller.ChatController 8 | import frangsierra.kotlinfirechat.chat.controller.ChatControllerImpl 9 | import frangsierra.kotlinfirechat.core.dagger.AppScope 10 | import frangsierra.kotlinfirechat.profile.store.ProfileStore 11 | import frangsierra.kotlinfirechat.session.store.SignOutAction 12 | import frangsierra.kotlinfirechat.util.taskRunning 13 | import io.reactivex.disposables.CompositeDisposable 14 | import mini.Reducer 15 | import mini.Store 16 | import javax.inject.Inject 17 | 18 | @AppScope 19 | class ChatStore @Inject constructor(val controller: ChatController, val profileStore: ProfileStore) : Store() { 20 | 21 | @Reducer 22 | fun loadMessages(action: StartListeningChatMessagesAction): ChatState { 23 | controller.startListeningMessages() 24 | return state 25 | } 26 | 27 | @Reducer 28 | fun messagesReceived(action: MessagesLoadedAction): ChatState { 29 | return state.copy(messages = state.messages.plus(action.messages.map { it.uid to it }.toMap())) 30 | } 31 | 32 | @Reducer 33 | fun sendMessage(action: SendMessageAction): ChatState { 34 | controller.sendMessage(action.message, action.attachedImageUri, profileStore.state.publicProfile!!) 35 | return state.copy(sendMessageTask = taskRunning()) 36 | } 37 | 38 | @Reducer 39 | fun messageSent(action: SendMessageCompleteAction): ChatState { 40 | return state.copy(sendMessageTask = action.task, 41 | messages = if (action.task.isSuccessful()) state.messages 42 | .plus(action.message!!.uid to action.message) else state.messages) 43 | } 44 | 45 | @Reducer 46 | fun stopListeningMessages(action: StopListeningChatMessagesAction): ChatState { 47 | state.disposables.dispose() 48 | return state.copy(disposables = CompositeDisposable()) 49 | } 50 | 51 | @Reducer 52 | fun signOut(action: SignOutAction): ChatState { 53 | return initialState() 54 | } 55 | } 56 | 57 | @Module 58 | abstract class ChatModule { 59 | @Binds 60 | @AppScope 61 | @IntoMap 62 | @ClassKey(ChatStore::class) 63 | abstract fun provideChatStore(store: ChatStore): Store<*> 64 | 65 | @Binds 66 | @AppScope 67 | abstract fun bindChatController(impl: ChatControllerImpl): ChatController 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import com.google.android.gms.common.ConnectionResult 6 | import com.google.android.gms.common.GoogleApiAvailability 7 | import frangsierra.kotlinfirechat.core.flux.FluxActivity 8 | import frangsierra.kotlinfirechat.home.HomeActivity 9 | import frangsierra.kotlinfirechat.session.LoginActivity 10 | import frangsierra.kotlinfirechat.session.store.SessionStore 11 | import frangsierra.kotlinfirechat.session.store.TryToLoginInFirstInstanceAction 12 | import frangsierra.kotlinfirechat.util.filterOne 13 | import frangsierra.kotlinfirechat.util.toast 14 | import javax.inject.Inject 15 | 16 | class SplashActivity : FluxActivity() { 17 | 18 | @Inject 19 | lateinit var sessionStore: SessionStore 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | 24 | if (checkPlayServices()) { 25 | dispatcher.dispatch(TryToLoginInFirstInstanceAction()) 26 | sessionStore.flowable() 27 | .filterOne { it.loginTask.isTerminal() } 28 | .subscribe { status -> 29 | if (status.loginTask.isSuccessful()) goToHome() 30 | else goToOnLogin() 31 | }.track() 32 | } 33 | } 34 | 35 | private fun goToHome() { 36 | val intent = HomeActivity.newIntent(this) 37 | intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 38 | startActivity(intent) 39 | } 40 | 41 | private fun goToOnLogin() { 42 | val intent = LoginActivity.newIntent(this) 43 | intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 44 | startActivity(intent) 45 | } 46 | 47 | /** 48 | * Check the device to make sure it has the proper Google Play Services version. 49 | * If it doesn't, display a dialog that allows users to download it from 50 | * Google Play or enable it in the device's system settings. 51 | */ 52 | private fun checkPlayServices(): Boolean { 53 | val apiAvailability = GoogleApiAvailability.getInstance() 54 | val resultCode = apiAvailability.isGooglePlayServicesAvailable(this) 55 | if (resultCode != ConnectionResult.SUCCESS) { 56 | if (apiAvailability.isUserResolvableError(resultCode)) { 57 | apiAvailability.getErrorDialog(this, resultCode, 666).show() 58 | } else { 59 | toast("Google play is not supported in this device") 60 | finish() 61 | } 62 | return false 63 | } 64 | return true 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/dagger/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.dagger 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.Component 6 | import dagger.Module 7 | import dagger.Provides 8 | import frangsierra.kotlinfirechat.chat.store.ChatModule 9 | import frangsierra.kotlinfirechat.core.SplashActivity 10 | import frangsierra.kotlinfirechat.core.errors.ErrorHandlingModule 11 | import frangsierra.kotlinfirechat.core.firebase.FirebaseModule 12 | import frangsierra.kotlinfirechat.core.flux.App 13 | import frangsierra.kotlinfirechat.core.flux.FluxActivity 14 | import frangsierra.kotlinfirechat.home.HomeActivity 15 | import frangsierra.kotlinfirechat.profile.store.ProfileModule 16 | import frangsierra.kotlinfirechat.session.CreateAccountActivity 17 | import frangsierra.kotlinfirechat.session.EmailVerificationActivity 18 | import frangsierra.kotlinfirechat.session.LoginActivity 19 | import frangsierra.kotlinfirechat.session.store.SessionModule 20 | import mini.Dispatcher 21 | import mini.StoreMap 22 | 23 | interface AppComponent { 24 | fun dispatcher(): Dispatcher 25 | fun stores(): StoreMap 26 | } 27 | 28 | @AppScope 29 | @Component(modules = [(AppModule::class), 30 | (FirebaseModule::class), 31 | (ChatModule::class), 32 | (ErrorHandlingModule::class), 33 | (ProfileModule::class), 34 | (SessionModule::class)]) 35 | 36 | interface DefaultAppComponent : AppComponent { 37 | fun inject(target: SplashActivity) 38 | fun inject(target: FluxActivity) 39 | fun inject(target: LoginActivity) 40 | fun inject(target: CreateAccountActivity) 41 | fun inject(target: EmailVerificationActivity) 42 | fun inject(target: HomeActivity) 43 | } 44 | 45 | @Module 46 | class AppModule(val app: App) { 47 | @Provides 48 | @AppScope 49 | fun provideDispatcher() = Dispatcher() 50 | 51 | @Provides 52 | fun provideApplication(): Application = app 53 | 54 | @Provides 55 | fun provideAppContext(): Context = app 56 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/dagger/DaggerUtils.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.dagger 2 | 3 | /** 4 | * Look for an injection method in the component and invoke it. 5 | */ 6 | fun inject(component: Any, target: Any) { 7 | val javaClass = target.javaClass 8 | try { 9 | val injectMethod = component.javaClass.getMethod("inject", javaClass) 10 | injectMethod.invoke(component, target) 11 | } catch (e: NoSuchMethodException) { 12 | throw UnsupportedOperationException( 13 | """No injection point for $javaClass in: ${component.javaClass}. 14 | Expected a method in the component with signature: 15 | fun inject($javaClass)""".trimMargin()) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/dagger/Scopes.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.dagger 2 | 3 | import javax.inject.Scope 4 | 5 | @Scope 6 | @kotlin.annotation.Retention(AnnotationRetention.SOURCE) 7 | annotation class AppScope 8 | 9 | @Scope 10 | @kotlin.annotation.Retention(AnnotationRetention.SOURCE) 11 | annotation class ActivityScope 12 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/errors/CrashlyticsHandler.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.errors 2 | 3 | import com.crashlytics.android.Crashlytics 4 | 5 | class CrashlyticsHandler(val defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler) : Thread.UncaughtExceptionHandler { 6 | 7 | val runtime by lazy { Runtime.getRuntime() } 8 | 9 | override fun uncaughtException(thread: Thread?, ex: Throwable?) { 10 | // Our custom logic goes here. For example calculate the memory heap 11 | val maxMemory = runtime.maxMemory() 12 | val freeMemory = runtime.freeMemory() 13 | val usedMemory = runtime.totalMemory() - freeMemory 14 | val availableMemory = maxMemory - usedMemory 15 | 16 | //Set values to Crashlytics 17 | Crashlytics.setLong("used_memory", usedMemory) 18 | Crashlytics.setLong("available_memory", availableMemory) 19 | 20 | // This will make Crashlytics do its job 21 | defaultUncaughtExceptionHandler.uncaughtException(thread, ex) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/errors/ErrorHandlingModule.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.errors 2 | 3 | import android.content.Context 4 | import com.crashlytics.android.Crashlytics 5 | import com.google.android.gms.auth.api.signin.GoogleSignInStatusCodes 6 | import com.google.android.gms.common.api.ApiException 7 | import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException 8 | import com.google.firebase.auth.FirebaseAuthInvalidUserException 9 | import com.google.firebase.auth.FirebaseAuthUserCollisionException 10 | import com.google.firebase.auth.FirebaseAuthWeakPasswordException 11 | import com.google.firebase.firestore.FirebaseFirestoreException 12 | import dagger.Binds 13 | import dagger.Module 14 | import frangsierra.kotlinfirechat.R 15 | import frangsierra.kotlinfirechat.core.dagger.AppScope 16 | import frangsierra.kotlinfirechat.session.model.FirebaseUserNotFound 17 | import mini.Dispatcher 18 | import mini.Grove 19 | import java.net.UnknownHostException 20 | import java.util.concurrent.TimeoutException 21 | import javax.inject.Inject 22 | import javax.net.ssl.SSLPeerUnverifiedException 23 | 24 | @Module 25 | @Suppress("UndocumentedPublicClass", "UndocumentedPublicFunction") 26 | interface ErrorHandlingModule { 27 | @Binds 28 | @AppScope 29 | fun provideErrorHandler(errorHandler: DefaultErrorHandler): ErrorHandler 30 | } 31 | 32 | /** 33 | * A basic application generated error with a message. 34 | */ 35 | class GenericError(message: String?, cause: Throwable?) : Exception(message, cause) 36 | 37 | /** 38 | * Interface that exposes methods to handle app errors. 39 | */ 40 | interface ErrorHandler { 41 | /** Generate an user friendly message for known errors. */ 42 | fun getMessageForError(e: Throwable?): String 43 | 44 | /** Handle the error, may result in a new activity being launched. */ 45 | fun handle(e: Throwable?) 46 | 47 | /** 48 | * Unwrap an error into the underlying http code for manual handling, 49 | * or null if the error is not http related 50 | * */ 51 | fun unwrapCode(e: Throwable?): Int? 52 | } 53 | 54 | /** 55 | * Class that implements the main handler of the application. It maps errors and exceptions to strings 56 | * or starts a new activity. 57 | */ 58 | class DefaultErrorHandler @Inject constructor(private val context: Context, 59 | private val dispatcher: Dispatcher) : ErrorHandler { 60 | 61 | @Suppress("UndocumentedPublicFunction") 62 | override fun getMessageForError(e: Throwable?): String { 63 | return when (e) { 64 | is GenericError -> e.message ?: context.getString(R.string.error_unknown) 65 | is UnknownHostException -> context.getString(R.string.error_no_connection) 66 | is SSLPeerUnverifiedException -> context.getString(R.string.error_invalid_certificate) 67 | is TimeoutException -> context.getString(R.string.error_weak_password) 68 | is FirebaseAuthWeakPasswordException -> context.getString(R.string.error_weak_password) 69 | is FirebaseAuthInvalidCredentialsException -> context.getString(R.string.error_invalid_password) 70 | is FirebaseAuthUserCollisionException -> context.getString(R.string.error_email_already_exist) 71 | is FirebaseAuthInvalidUserException -> context.getString(R.string.error_invalid_account) 72 | is FirebaseUserNotFound -> context.getString(R.string.error_invalid_account) 73 | is ApiException -> { 74 | "${context.getString(R.string.error_google)} ${GoogleSignInStatusCodes.getStatusCodeString(e.statusCode)}" 75 | } 76 | is FirebaseFirestoreException -> retrieveFirebaseErrorMessage(e) 77 | else -> { 78 | Grove.e { "Unexpected error: $e" } 79 | context.getString(R.string.error_unexpected) 80 | } 81 | } 82 | } 83 | 84 | @Suppress("UndocumentedPublicFunction") 85 | override fun handle(e: Throwable?) { 86 | val exception = e as? Exception ?: Exception(e) 87 | Crashlytics.logException(exception) 88 | val errorCode = unwrapCode(e) 89 | if (errorCode == 401) { 90 | // Unauthorized 91 | } 92 | } 93 | 94 | @Suppress("UndocumentedPublicFunction") 95 | override fun unwrapCode(e: Throwable?): Int? { 96 | return null 97 | } 98 | 99 | private fun retrieveFirebaseErrorMessage(error: FirebaseFirestoreException): String { 100 | when (error.code) { 101 | //OK -> TODO() 102 | //CANCELLED -> TODO() 103 | //UNKNOWN -> TODO() 104 | //INVALID_ARGUMENT -> TODO() 105 | //DEADLINE_EXCEEDED -> TODO() 106 | //NOT_FOUND -> TODO() 107 | //ALREADY_EXISTS -> TODO() 108 | //PERMISSION_DENIED -> TODO() 109 | //RESOURCE_EXHAUSTED -> TODO() 110 | //FAILED_PRECONDITION -> TODO() 111 | //ABORTED -> TODO() 112 | //OUT_OF_RANGE -> TODO() 113 | //UNIMPLEMENTED -> TODO() 114 | //INTERNAL -> TODO() 115 | //UNAVAILABLE -> TODO() 116 | //DATA_LOSS -> TODO() 117 | //UNAUTHENTICATED -> TODO() 118 | } 119 | return context.getString(R.string.error_unknown) 120 | } 121 | } 122 | 123 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/firebase/FirebaseConstants.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.firebase 2 | 3 | 4 | object FirebaseConstants { 5 | 6 | const val LAST_LOGIN = "lastLogin" 7 | const val TOTAL_MESSAGES = "totalMessages" 8 | 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/firebase/FirebaseModels.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.firebase 2 | 3 | import com.google.firebase.Timestamp 4 | import com.google.firebase.firestore.DocumentSnapshot 5 | import com.google.firebase.firestore.ServerTimestamp 6 | import frangsierra.kotlinfirechat.chat.model.Message 7 | import frangsierra.kotlinfirechat.profile.model.PrivateData 8 | import frangsierra.kotlinfirechat.profile.model.PublicProfile 9 | import frangsierra.kotlinfirechat.profile.model.UserData 10 | import java.util.* 11 | 12 | fun UserData.toFirebaseUserData() = FirebaseUserData(username, photoUrl, uid) 13 | 14 | data class FirebaseUserData(val username: String = "", 15 | val photoUrl: String? = null, 16 | var uid: String = "") 17 | 18 | fun FirebaseUserData.toUserData() = UserData(username = username, photoUrl = photoUrl, uid = uid) 19 | 20 | data class FirebaseMessage(val author: FirebaseUserData = FirebaseUserData(), 21 | val message: String = "", 22 | val attachedImageUrl : String? = null, 23 | @ServerTimestamp val timestamp: Timestamp? = null) 24 | 25 | fun DocumentSnapshot.toMessage() = toObject(FirebaseMessage::class.java)!!.toMessage(id) 26 | 27 | fun FirebaseMessage.toMessage(id: String) = Message(uid = id, 28 | author = author.toUserData(), 29 | message = message, 30 | attachedImageUrl = attachedImageUrl, 31 | timestamp = timestamp?.toDate() ?: Timestamp.now().toDate()) 32 | 33 | data class FirebasePrivateData( 34 | val email: String = "", 35 | val messagingTokens: List = emptyList() 36 | ) 37 | 38 | fun DocumentSnapshot.toPrivateData() = toObject(FirebasePrivateData::class.java)!!.toPrivateData(id) 39 | 40 | fun FirebasePrivateData.toPrivateData(id: String) = PrivateData(uid = id, 41 | email = email, 42 | messagingTokens = messagingTokens) 43 | 44 | data class FirebasePublicProfile( 45 | val userData: FirebaseUserData = FirebaseUserData(), 46 | val lowerCaseUsername: String = "", 47 | val totalMessages: Int = 0, 48 | @ServerTimestamp val lastLogin: Timestamp? = null 49 | ) 50 | 51 | fun DocumentSnapshot.toPublicProfile() = toObject(FirebasePublicProfile::class.java)!!.toPublicProfile() 52 | 53 | fun FirebasePublicProfile.toPublicProfile() = PublicProfile(userData = userData.toUserData(), 54 | lowerCaseUsername = lowerCaseUsername, 55 | totalMessages = totalMessages, 56 | lastLogin = lastLogin?.toDate() ?: Timestamp.now().toDate()) -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/firebase/FirebaseModule.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.firebase 2 | 3 | import android.content.Context 4 | import com.firebase.jobdispatcher.FirebaseJobDispatcher 5 | import com.firebase.jobdispatcher.GooglePlayDriver 6 | import com.google.firebase.analytics.FirebaseAnalytics 7 | import com.google.firebase.auth.FirebaseAuth 8 | import com.google.firebase.firestore.FirebaseFirestore 9 | import com.google.firebase.firestore.FirebaseFirestoreSettings 10 | import com.google.firebase.iid.FirebaseInstanceId 11 | import com.google.firebase.storage.FirebaseStorage 12 | import dagger.Module 13 | import dagger.Provides 14 | import frangsierra.kotlinfirechat.core.dagger.AppScope 15 | import frangsierra.kotlinfirechat.core.flux.app 16 | 17 | 18 | @Module 19 | class FirebaseModule { 20 | @Provides 21 | @AppScope 22 | fun provideAuthentication(): FirebaseAuth = FirebaseAuth.getInstance() 23 | 24 | @Provides 25 | @AppScope 26 | fun provideInstanceId(): FirebaseInstanceId = FirebaseInstanceId.getInstance() 27 | 28 | @Provides 29 | @AppScope 30 | fun provideAnalytics(): FirebaseAnalytics = FirebaseAnalytics.getInstance(app) 31 | 32 | @Provides 33 | @AppScope 34 | fun provideStorage(): FirebaseStorage = FirebaseStorage.getInstance() 35 | 36 | @Provides 37 | @AppScope 38 | fun provideFirestore(): FirebaseFirestore = FirebaseFirestore.getInstance().apply { 39 | val settings = FirebaseFirestoreSettings.Builder() 40 | .setTimestampsInSnapshotsEnabled(true) 41 | .setPersistenceEnabled(true) 42 | .build() 43 | firestoreSettings = settings 44 | } 45 | 46 | @Provides @AppScope 47 | fun provideFirebaseDispatcher(context: Context): FirebaseJobDispatcher = FirebaseJobDispatcher(GooglePlayDriver(context)) 48 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/firebase/FirebaseRefs.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.firebase 2 | 3 | import com.google.firebase.firestore.FirebaseFirestore 4 | import com.google.firebase.storage.FirebaseStorage 5 | 6 | const val DEFAULT_QUERY_LIMIT = 10L 7 | const val PUBLIC_PROFILE = "PublicProfile" 8 | const val PRIVATE_DATA = "PrivateData" 9 | const val MESSAGES = "Messages" 10 | const val STORAGE_USER = "User" 11 | const val STORAGE_MESSAGES = "Messages" 12 | 13 | fun FirebaseFirestore.publicProfile() = collection(PUBLIC_PROFILE) 14 | 15 | fun FirebaseFirestore.publicProfileDoc(uid: String) = publicProfile().document(uid) 16 | 17 | fun FirebaseFirestore.privateData() = collection(PRIVATE_DATA) 18 | 19 | fun FirebaseFirestore.privateDataDoc(uid: String) = privateData().document(uid) 20 | 21 | fun FirebaseFirestore.messages() = collection(MESSAGES) 22 | 23 | fun FirebaseFirestore.messageDoc(uid: String) = messages().document(uid) 24 | 25 | fun FirebaseStorage.chatStorageMessageRef(userId: String, uid: String) = reference 26 | .child(STORAGE_USER) 27 | .child(userId) 28 | .child(STORAGE_MESSAGES) 29 | .child(uid) 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/flux/App.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.flux 2 | 3 | import android.app.Application 4 | import com.crashlytics.android.Crashlytics 5 | import frangsierra.kotlinfirechat.BuildConfig 6 | import frangsierra.kotlinfirechat.core.dagger.AppComponent 7 | import frangsierra.kotlinfirechat.core.dagger.AppModule 8 | import frangsierra.kotlinfirechat.core.dagger.DaggerDefaultAppComponent 9 | import frangsierra.kotlinfirechat.core.errors.CrashlyticsHandler 10 | import frangsierra.kotlinfirechat.util.Prefs 11 | import io.fabric.sdk.android.Fabric 12 | import mini.MiniActionReducer 13 | import org.jetbrains.annotations.TestOnly 14 | import kotlin.properties.Delegates 15 | 16 | 17 | private var _app: App? = null 18 | 19 | private var _prefs: Prefs by Delegates.notNull() 20 | private var _appComponent: AppComponent? = null 21 | val app: App get() = _app!! 22 | val prefs: Prefs get() = _prefs 23 | val appComponent: AppComponent get() = _appComponent!! 24 | 25 | @TestOnly 26 | fun setAppComponent(component: AppComponent) { 27 | _appComponent = component 28 | } 29 | 30 | class App : Application() { 31 | override fun onCreate() { 32 | super.onCreate() 33 | _app = this 34 | _prefs = Prefs(this) 35 | 36 | //Initialize Fabric before add the custom UncaughtExceptionHandler! 37 | val fabric = Fabric.Builder(this) 38 | .kits(Crashlytics()) 39 | .debuggable(BuildConfig.DEBUG) // Enables Crashlytics debugger 40 | .build() 41 | Fabric.with(fabric) 42 | 43 | val defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() 44 | Thread.setDefaultUncaughtExceptionHandler(CrashlyticsHandler(defaultUncaughtExceptionHandler)) 45 | 46 | if (_appComponent == null) { 47 | _appComponent = DaggerDefaultAppComponent 48 | .builder() 49 | .appModule(AppModule(this)) 50 | .build() 51 | _appComponent!!.dispatcher().actionReducers.add(MiniActionReducer(stores = _appComponent!!.stores())) 52 | _appComponent!!.dispatcher().addInterceptor(CustomLoggerInterceptor 53 | (_appComponent!!.stores().values)) 54 | } 55 | 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/flux/Async.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.flux 2 | 3 | import android.os.Handler 4 | import android.os.Looper 5 | import java.lang.ref.WeakReference 6 | import java.util.concurrent.ExecutorService 7 | import java.util.concurrent.Executors 8 | import java.util.concurrent.Future 9 | 10 | class AsyncContext(val weakRef: WeakReference) 11 | 12 | private val crashLogger = { throwable : Throwable -> throwable.printStackTrace() } 13 | /** 14 | * Execute [task] asynchronously. 15 | * 16 | * @param exceptionHandler optional exception handler. 17 | * If defined, any exceptions thrown inside [task] will be passed to it. If not, exceptions will be ignored. 18 | * @param task the code to execute asynchronously. 19 | */ 20 | fun T.doAsync( 21 | exceptionHandler: ((Throwable) -> Unit)? = crashLogger, 22 | task: AsyncContext.() -> Unit 23 | ): Future { 24 | val context = AsyncContext(WeakReference(this)) 25 | return BackgroundExecutor.submit { 26 | return@submit try { 27 | context.task() 28 | } catch (thr: Throwable) { 29 | val result = exceptionHandler?.invoke(thr) 30 | if (result != null) { 31 | result 32 | } else { 33 | Unit 34 | } 35 | } 36 | } 37 | } 38 | 39 | fun T.doAsync( 40 | exceptionHandler: ((Throwable) -> Unit)? = crashLogger, 41 | executorService: ExecutorService, 42 | task: AsyncContext.() -> Unit 43 | ): Future { 44 | val context = AsyncContext(WeakReference(this)) 45 | return executorService.submit { 46 | try { 47 | context.task() 48 | } catch (thr: Throwable) { 49 | exceptionHandler?.invoke(thr) 50 | } 51 | } 52 | } 53 | 54 | fun T.doAsyncResult( 55 | exceptionHandler: ((Throwable) -> Unit)? = crashLogger, 56 | task: AsyncContext.() -> R 57 | ): Future { 58 | val context = AsyncContext(WeakReference(this)) 59 | return BackgroundExecutor.submit { 60 | try { 61 | context.task() 62 | } catch (thr: Throwable) { 63 | exceptionHandler?.invoke(thr) 64 | throw thr 65 | } 66 | } 67 | } 68 | 69 | fun T.doAsyncResult( 70 | exceptionHandler: ((Throwable) -> Unit)? = crashLogger, 71 | executorService: ExecutorService, 72 | task: AsyncContext.() -> R 73 | ): Future { 74 | val context = AsyncContext(WeakReference(this)) 75 | return executorService.submit { 76 | try { 77 | context.task() 78 | } catch (thr: Throwable) { 79 | exceptionHandler?.invoke(thr) 80 | throw thr 81 | } 82 | } 83 | } 84 | 85 | internal object BackgroundExecutor { 86 | var executor: ExecutorService = 87 | Executors.newScheduledThreadPool(2 * Runtime.getRuntime().availableProcessors()) 88 | 89 | fun submit(task: () -> T): Future = executor.submit(task) 90 | 91 | } 92 | 93 | private object ContextHelper { 94 | val handler = Handler(Looper.getMainLooper()) 95 | val mainThread: Thread = Looper.getMainLooper().thread 96 | } 97 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/flux/CustomLoggerInterceptor.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.flux 2 | 3 | import io.reactivex.Completable 4 | import io.reactivex.schedulers.Schedulers 5 | import mini.* 6 | 7 | /** Actions implementing this interface won't log anything */ 8 | interface SilentActionTag 9 | 10 | class CustomLoggerInterceptor constructor(stores: Collection>, 11 | private val logInBackground: Boolean = false) : Interceptor { 12 | 13 | private val stores = stores.toList() 14 | private var lastActionTime = System.currentTimeMillis() 15 | private var actionCounter: Long = 0 16 | 17 | override fun invoke(action: Action, chain: Chain): Action { 18 | if (action is SilentActionTag) return chain.proceed(action) //Do nothing 19 | 20 | val beforeStates: Array = Array(stores.size, { _ -> Unit }) 21 | val afterStates: Array = Array(stores.size, { _ -> Unit }) 22 | 23 | stores.forEachIndexed({ idx, store -> beforeStates[idx] = store.state }) 24 | val start = System.currentTimeMillis() 25 | val timeSinceLastAction = Math.min(start - lastActionTime, 9999) 26 | lastActionTime = start 27 | actionCounter++ 28 | val out = chain.proceed(action) 29 | val processTime = System.currentTimeMillis() - start 30 | stores.forEachIndexed({ idx, store -> afterStates[idx] = store.state }) 31 | 32 | if (action is SilentActionTag) return out 33 | 34 | Completable.fromAction { 35 | val sb = StringBuilder() 36 | sb.append("┌────────────────────────────────────────────\n") 37 | sb.append(String.format("├─> %s %dms [+%dms][%d] - %s", 38 | action.javaClass.simpleName, processTime, timeSinceLastAction, actionCounter % 10, action)) 39 | .append("\n") 40 | 41 | // Log whether an interceptor changed the action and display the resulting action 42 | if (out != action) { 43 | sb.append(String.format("│ %s", "=== Action has been intercepted, result: ===")).append("\n") 44 | sb.append(String.format("├─> %s %dms [+%dms][%d] - %s", 45 | out.javaClass.simpleName, processTime, timeSinceLastAction, actionCounter % 10, out)) 46 | .append("\n") 47 | } 48 | 49 | for (i in beforeStates.indices) { 50 | val oldState = beforeStates[i] 51 | val newState = afterStates[i] 52 | if (oldState !== newState) { 53 | //This operation is costly, don't do it in prod 54 | val line = "${stores[i].javaClass.simpleName}: $newState" 55 | sb.append(String.format("│ %s", line)).append("\n") 56 | } 57 | } 58 | 59 | sb.append("└────────────────────────────────────────────\n") 60 | if (action !is SilentActionTag) { 61 | Grove.i { "LoggerStore $sb" } 62 | } 63 | }.let { 64 | if (logInBackground) it.subscribeOn(Schedulers.single()) 65 | else it 66 | }.subscribe() 67 | 68 | return out 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/flux/FluxActivity.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.flux 2 | 3 | import android.os.Bundle 4 | import android.support.v7.app.AppCompatActivity 5 | import frangsierra.kotlinfirechat.core.dagger.inject 6 | import frangsierra.kotlinfirechat.core.flux.appComponent 7 | import mini.DefaultSubscriptionTracker 8 | import mini.Dispatcher 9 | import mini.SubscriptionTracker 10 | import javax.inject.Inject 11 | 12 | abstract class FluxActivity : AppCompatActivity(), 13 | SubscriptionTracker by DefaultSubscriptionTracker() { 14 | 15 | @Inject 16 | lateinit protected var dispatcher: Dispatcher 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | inject(appComponent, this) 21 | } 22 | 23 | override fun onDestroy() { 24 | super.onDestroy() 25 | cancelSubscriptions() 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/flux/FluxFragment.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.common.flux 2 | 3 | import android.support.annotation.CallSuper 4 | import android.support.v4.app.Fragment 5 | import mini.DefaultSubscriptionTracker 6 | import mini.Dispatcher 7 | import mini.SubscriptionTracker 8 | import javax.inject.Inject 9 | 10 | /** 11 | * Custom [Fragment] capable of tracking subscriptions and cancel them when the fragment 12 | * is destroyed in order to avoid possible memory leaks. 13 | */ 14 | open class FluxFragment : 15 | Fragment(), 16 | SubscriptionTracker by DefaultSubscriptionTracker() { 17 | 18 | @Inject 19 | lateinit var dispatcher: Dispatcher 20 | 21 | @Suppress("KDocMissingDocumentation") 22 | @CallSuper 23 | override fun onDestroyView() { 24 | super.onDestroyView() 25 | cancelSubscriptions() 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/core/service/DataUploadService.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.core.service 2 | 3 | import android.net.Uri 4 | import android.os.Bundle 5 | import com.firebase.jobdispatcher.* 6 | import com.google.android.gms.tasks.Tasks 7 | import com.google.firebase.firestore.FirebaseFirestore 8 | import com.google.firebase.storage.FirebaseStorage 9 | import com.google.firebase.storage.StorageReference 10 | import com.google.firebase.storage.UploadTask 11 | import frangsierra.kotlinfirechat.core.firebase.chatStorageMessageRef 12 | import frangsierra.kotlinfirechat.core.firebase.messageDoc 13 | import frangsierra.kotlinfirechat.util.ImageUtils.resizeUriToPostImage 14 | import io.reactivex.Single 15 | import io.reactivex.schedulers.Schedulers 16 | import mini.Grove 17 | 18 | private const val MESSAGE_ID_SERVICE_KEY = "message_service_id" 19 | private const val USER_ID_SERVICE_KEY = "user_service_id" 20 | private const val UPLOAD_FILE_URL_KEY = "file_url_id" 21 | 22 | class DataUploadService : JobService() { 23 | private val firebaseStorage: FirebaseStorage = FirebaseStorage.getInstance() 24 | private val firebaseFirestore: FirebaseFirestore = FirebaseFirestore.getInstance() 25 | 26 | override fun onStartJob(job: JobParameters): Boolean { 27 | Grove.d { "Crash reporting job called ${job.tag}" } 28 | 29 | val extras = job.extras ?: throw NullPointerException("Bundle can't be null") 30 | 31 | val messageID = extras.getString(MESSAGE_ID_SERVICE_KEY) 32 | val userId = extras.getString(USER_ID_SERVICE_KEY) 33 | val fileUri = Uri.parse(extras.getString(UPLOAD_FILE_URL_KEY)) 34 | 35 | uploadFile(job, messageID, userId, fileUri) 36 | return true 37 | } 38 | 39 | private fun uploadFile(job: JobParameters, messageId: String, userId: String, fileUrl: Uri) { 40 | resizeUriToPostImage(fileUrl).flatMap { bytes -> putBytes(firebaseStorage.chatStorageMessageRef(userId, messageId), bytes) } 41 | .subscribeOn(Schedulers.io()) 42 | .observeOn(Schedulers.io()) 43 | .subscribe( 44 | { task -> 45 | try { 46 | val uri = Tasks.await(task.storage.downloadUrl) 47 | Tasks.await(firebaseFirestore.messageDoc(messageId).update("attachedImageUrl", uri.toString())) 48 | jobFinished(job, false) 49 | } catch (e: Exception) { 50 | jobFinished(job, true) 51 | } 52 | }, { 53 | jobFinished(job, true) 54 | }) 55 | } 56 | 57 | /** 58 | * onStopJob is only called when we manually cancel the current jobs, 59 | * this happens when the user disconnects his wifi network. 60 | */ 61 | override fun onStopJob(job: JobParameters): Boolean { 62 | Grove.d { "Crash reporting job stopped ${job.tag}" } 63 | //onStop should return true to re-schedule the canceled job. 64 | return true 65 | } 66 | } 67 | 68 | fun buildUploadJob(uri: String, userId: String, messageId: String, builder: Job.Builder): Job { 69 | val myExtrasBundle = Bundle() 70 | 71 | myExtrasBundle.putString(MESSAGE_ID_SERVICE_KEY, messageId) 72 | myExtrasBundle.putString(USER_ID_SERVICE_KEY, userId) 73 | myExtrasBundle.putString(UPLOAD_FILE_URL_KEY, uri) 74 | 75 | return builder 76 | .setService(DataUploadService::class.java) // the JobService that will be called 77 | .setTag("$messageId-$userId") // uniquely identifies the job 78 | .setRecurring(false) // one-off job 79 | .setLifetime(Lifetime.FOREVER) // don't persist past a device reboot 80 | .setTrigger(Trigger.NOW) // start immediately 81 | .setReplaceCurrent(true) // don't overwrite an existing job with the same tag 82 | .setRetryStrategy(RetryStrategy.DEFAULT_LINEAR) // retry with linear backoff 83 | .setExtras(myExtrasBundle) 84 | .build() 85 | } 86 | 87 | internal fun putBytes(storageRef: StorageReference, bytes: ByteArray): Single { 88 | return Single.create { emitter -> 89 | val taskSnapshotStorageTask = storageRef.putBytes(bytes) 90 | .addOnSuccessListener { taskSnapshot -> emitter.onSuccess(taskSnapshot) } 91 | .addOnFailureListener { e -> 92 | if (!emitter.isDisposed) { 93 | emitter.onError(e) 94 | } 95 | } 96 | emitter.setCancellable { taskSnapshotStorageTask.cancel() } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/home/HomeActivity.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.home 2 | 3 | import android.Manifest 4 | import android.app.Activity 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.net.Uri 8 | import android.os.Bundle 9 | import android.support.v4.content.ContextCompat 10 | import com.crashlytics.android.Crashlytics 11 | import com.tbruyelle.rxpermissions2.RxPermissions 12 | import frangsierra.kotlinfirechat.R 13 | import frangsierra.kotlinfirechat.chat.store.ChatStore 14 | import frangsierra.kotlinfirechat.chat.store.SendMessageAction 15 | import frangsierra.kotlinfirechat.chat.store.StartListeningChatMessagesAction 16 | import frangsierra.kotlinfirechat.chat.store.StopListeningChatMessagesAction 17 | import frangsierra.kotlinfirechat.core.errors.ErrorHandler 18 | import frangsierra.kotlinfirechat.core.flux.FluxActivity 19 | import frangsierra.kotlinfirechat.profile.store.ProfileStore 20 | import frangsierra.kotlinfirechat.session.LoginActivity 21 | import frangsierra.kotlinfirechat.util.* 22 | import kotlinx.android.synthetic.main.home_activity.* 23 | import javax.inject.Inject 24 | 25 | class HomeActivity : FluxActivity() { 26 | 27 | @Inject 28 | lateinit var profileStore: ProfileStore 29 | @Inject 30 | lateinit var chatStore: ChatStore 31 | @Inject 32 | lateinit var errorHandler: ErrorHandler 33 | 34 | companion object { 35 | fun newIntent(context: Context): Intent = 36 | Intent(context, HomeActivity::class.java) 37 | } 38 | 39 | private val messageAdapter = MessageAdapter() 40 | private var outputFileUri: Uri? = null 41 | //FIXME inject me if it's used somewhere else 42 | private val rxPermissionInstance: RxPermissions by lazy { RxPermissions(this) } 43 | 44 | override fun onCreate(savedInstanceState: Bundle?) { 45 | super.onCreate(savedInstanceState) 46 | setContentView(R.layout.home_activity) 47 | initializeInterface() 48 | startListeningStoreChanges() 49 | } 50 | 51 | private fun initializeInterface() { 52 | messageRecycler.setLinearLayoutManager(this, reverseLayout = false, stackFromEnd = false) 53 | messageRecycler.adapter = messageAdapter 54 | sendButton.setOnClickListener { sendMessage() } 55 | photoPickerButton.setOnClickListener { requestPermissionsAndPickImage() } 56 | } 57 | 58 | private fun startListeningStoreChanges() { 59 | profileStore.flowable() 60 | .view { it.loadProfileTask } 61 | .subscribe { 62 | when (it.status) { 63 | TypedTask.Status.RUNNING -> showProgressDialog("Loading user profile") 64 | TypedTask.Status.SUCCESS -> dismissProgressDialog() 65 | TypedTask.Status.FAILURE -> goToLogin() 66 | } 67 | }.track() 68 | 69 | chatStore.flowable() 70 | .view { it.messages } 71 | .filter { it.isNotEmpty() } 72 | .subscribe { messageAdapter.updateMessages(it.values.toList()) } 73 | .track() 74 | } 75 | 76 | private fun sendMessage() { 77 | if (messageEditText.text.isEmpty()) { 78 | toast("You should add a text") 79 | return 80 | } 81 | sendButton.isEnabled = false 82 | dispatcher.dispatchOnUi(SendMessageAction(messageEditText.text.toString(), outputFileUri)) 83 | messageEditText.text.clear() 84 | chatStore.flowable() 85 | .filterOne { it.sendMessageTask.isTerminal() } //Wait for request to finish 86 | .subscribe { 87 | if (it.sendMessageTask.isFailure()) { 88 | errorHandler.handle(it.sendMessageTask.error) 89 | toast("There was an error sending your message") 90 | } 91 | sendButton.isEnabled = true 92 | }.track() 93 | } 94 | 95 | private fun goToLogin() { 96 | dismissProgressDialog() 97 | val intent = LoginActivity.newIntent(this).apply { 98 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 99 | } 100 | startActivity(intent) 101 | } 102 | 103 | private fun requestPermissionsAndPickImage() { 104 | rxPermissionInstance.request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) 105 | .subscribe { 106 | if (it) { 107 | outputFileUri = AndroidUtils.generateUniqueFireUri(this) 108 | AndroidUtils.showImageIntentDialog(this, outputFileUri!!) 109 | } 110 | } 111 | } 112 | 113 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 114 | if (resultCode != Activity.RESULT_CANCELED) { 115 | when (requestCode) { 116 | TC_REQUEST_GALLERY, TC_REQUEST_CAMERA -> 117 | if (resultCode == Activity.RESULT_OK) { 118 | val uri = if (requestCode == TC_REQUEST_GALLERY) data?.data else outputFileUri 119 | if (uri == null) { 120 | Crashlytics.log("Uri was null when updating profile picture and using code $resultCode") 121 | toast(getString(R.string.error_picture_null)) 122 | return 123 | } 124 | onImageReady() 125 | } 126 | } 127 | } 128 | } 129 | 130 | private fun onImageReady() { 131 | toast("your image have been attached") 132 | //TODO move to selector 133 | photoPickerButton.setColorFilter(ContextCompat.getColor(this, R.color.image_picked_color), 134 | android.graphics.PorterDuff.Mode.MULTIPLY) 135 | } 136 | 137 | override fun onStart() { 138 | super.onStart() 139 | dispatcher.dispatch(StartListeningChatMessagesAction()) 140 | } 141 | 142 | override fun onStop() { 143 | super.onStop() 144 | dispatcher.dispatch(StopListeningChatMessagesAction()) 145 | } 146 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/home/MessageAdapter.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.home 2 | 3 | 4 | import android.support.v7.util.DiffUtil 5 | import android.support.v7.util.DiffUtil.calculateDiff 6 | import android.support.v7.widget.RecyclerView 7 | import android.view.View 8 | import android.widget.ImageView 9 | import android.widget.TextView 10 | import frangsierra.kotlinfirechat.R 11 | import frangsierra.kotlinfirechat.chat.model.Message 12 | import frangsierra.kotlinfirechat.util.setCircularImage 13 | import frangsierra.kotlinfirechat.util.setImage 14 | import kotlinx.android.synthetic.main.item_message.view.* 15 | 16 | class MessageAdapter : RecyclerView.Adapter() { 17 | private val messageList: MutableList = java.util.ArrayList() 18 | 19 | override fun onBindViewHolder(holder: MessageAdapter.MessageViewHolder, position: Int) { 20 | with(messageList[position]) { 21 | holder.messageTextView.text = message 22 | holder.authorTextView.text = author.username 23 | //holder.timeAgoTextView.text = timestamp.time 24 | if (author.photoUrl == null) { 25 | holder.photoImageView.visibility = View.GONE 26 | } else { 27 | holder.photoImageView.setCircularImage(author.photoUrl) 28 | holder.photoImageView.visibility = View.VISIBLE 29 | } 30 | if (attachedImageUrl == null) { 31 | holder.messagePhoto.visibility = View.GONE 32 | } else { 33 | holder.messagePhoto.setImage(attachedImageUrl) { holder.messagePhoto.visibility = View.GONE} 34 | holder.messagePhoto.visibility = View.VISIBLE 35 | } 36 | 37 | } 38 | } 39 | 40 | override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): MessageAdapter.MessageViewHolder { 41 | val v = android.view.LayoutInflater.from(parent.context).inflate(R.layout.item_message, parent, false) 42 | return MessageViewHolder(v) 43 | } 44 | 45 | override fun getItemCount(): Int { 46 | return messageList.size 47 | } 48 | 49 | fun updateMessages(messages: List) { 50 | val diffResult = calculateDiff(MessageDiff(this.messageList, messages)) 51 | messageList.clear() 52 | messageList.addAll(messages) 53 | diffResult.dispatchUpdatesTo(this) 54 | } 55 | 56 | inner class MessageViewHolder(v: android.view.View) : RecyclerView.ViewHolder(v) { 57 | val photoImageView: ImageView = v.comment_author_picture 58 | val messagePhoto: ImageView = v.comment_picture 59 | val messageTextView: TextView = v.comment_description 60 | val authorTextView: TextView = v.comment_author_username 61 | val timeAgoTextView: TextView = v.comment_time_ago 62 | } 63 | 64 | inner class MessageDiff(val oldList: List, val newList: List) : DiffUtil.Callback() { 65 | 66 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { 67 | return oldList[oldItemPosition].uid == newList[newItemPosition].uid 68 | } 69 | 70 | override fun getOldListSize(): Int { 71 | return oldList.size 72 | } 73 | 74 | override fun getNewListSize(): Int { 75 | return newList.size 76 | } 77 | 78 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { 79 | return oldList[oldItemPosition] == newList[newItemPosition] 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/profile/controller/ProfileController.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.profile.controller 2 | 3 | import com.google.android.gms.tasks.Tasks 4 | import com.google.firebase.Timestamp 5 | import com.google.firebase.firestore.FirebaseFirestore 6 | import frangsierra.kotlinfirechat.core.dagger.AppScope 7 | import frangsierra.kotlinfirechat.core.firebase.* 8 | import frangsierra.kotlinfirechat.core.firebase.FirebaseConstants.LAST_LOGIN 9 | import frangsierra.kotlinfirechat.core.flux.doAsync 10 | import frangsierra.kotlinfirechat.profile.model.PrivateData 11 | import frangsierra.kotlinfirechat.profile.model.PublicProfile 12 | import frangsierra.kotlinfirechat.profile.model.UserData 13 | import frangsierra.kotlinfirechat.profile.store.LoadUserDataCompleteAction 14 | import frangsierra.kotlinfirechat.session.model.User 15 | import frangsierra.kotlinfirechat.util.taskFailure 16 | import frangsierra.kotlinfirechat.util.taskSuccess 17 | import mini.Dispatcher 18 | import javax.inject.Inject 19 | 20 | interface ProfileController { 21 | fun loadUserProfile(user: User) 22 | } 23 | 24 | @AppScope 25 | class ProfileControllerImpl @Inject constructor(private val firestore: FirebaseFirestore, val dispatcher: Dispatcher) : ProfileController { 26 | override fun loadUserProfile(user: User) { 27 | doAsync { 28 | try { 29 | val privateData = getAndCreateIfNoyExistsPrivateData(user) 30 | val publicProfile = getAndCreateIfNotExistsPublicData(user) 31 | updateUserLastLogin(userId = user.uid) 32 | dispatcher.dispatchOnUi(LoadUserDataCompleteAction(privateData, publicProfile, taskSuccess())) 33 | } catch (e: Throwable) { 34 | dispatcher.dispatchOnUi(LoadUserDataCompleteAction(null, null, taskFailure(e))) 35 | } 36 | } 37 | } 38 | 39 | private fun updateUserLastLogin(userId: String) { 40 | Tasks.await(firestore.publicProfileDoc(userId).update(LAST_LOGIN, Timestamp.now())) 41 | } 42 | 43 | private fun getAndCreateIfNoyExistsPrivateData(user: User): PrivateData { 44 | val privateDataDocument = Tasks.await(firestore.privateDataDoc(user.uid).get()) 45 | return if (privateDataDocument.exists()) privateDataDocument.toPrivateData() 46 | else { 47 | val firebasePrivateData = FirebasePrivateData(user.email) 48 | Tasks.await(firestore.privateDataDoc(user.uid).set(firebasePrivateData)) 49 | firebasePrivateData.toPrivateData(user.uid) 50 | } 51 | } 52 | 53 | private fun getAndCreateIfNotExistsPublicData(user: User): PublicProfile { 54 | val userData = UserData(user.username, user.photoUrl, user.uid) 55 | val publicProfile = Tasks.await(firestore.publicProfileDoc(userData.uid).get()) 56 | return if (publicProfile.exists()) publicProfile.toPublicProfile() 57 | else { 58 | val firebasePublicProfile = FirebasePublicProfile(userData.toFirebaseUserData(), userData.username.toLowerCase()) 59 | Tasks.await(firestore.publicProfileDoc(userData.uid).set(firebasePublicProfile)) 60 | firebasePublicProfile.toPublicProfile() 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/profile/model/Models.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.profile.model 2 | 3 | import com.google.firebase.firestore.ServerTimestamp 4 | import java.util.* 5 | 6 | 7 | data class PrivateData( 8 | val uid: String = "", 9 | val email: String = "", 10 | val messagingTokens: List = emptyList() 11 | ) 12 | 13 | data class PublicProfile( 14 | val userData: UserData = UserData(), 15 | val lowerCaseUsername: String = "", 16 | val totalMessages: Int = 0, 17 | val lastLogin: Date 18 | ) 19 | 20 | data class UserData(val username: String = "", 21 | val photoUrl: String? = null, 22 | var uid: String = "") { 23 | val loaded = !uid.isEmpty() 24 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/profile/store/ProfileActions.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.profile.store 2 | 3 | import frangsierra.kotlinfirechat.profile.model.PrivateData 4 | import frangsierra.kotlinfirechat.profile.model.PublicProfile 5 | import frangsierra.kotlinfirechat.util.Task 6 | import mini.Action 7 | 8 | data class LoadUserDataCompleteAction(val privateData: PrivateData?, 9 | val publicProfile: PublicProfile?, 10 | val task: Task) : Action -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/profile/store/ProfileState.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.profile.store 2 | 3 | import frangsierra.kotlinfirechat.profile.model.PrivateData 4 | import frangsierra.kotlinfirechat.profile.model.PublicProfile 5 | import frangsierra.kotlinfirechat.util.Task 6 | 7 | data class ProfileState(val privateData: PrivateData? = null, 8 | val publicProfile: PublicProfile? = null, 9 | val loadProfileTask: Task = Task()) -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/profile/store/ProfileStore.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.profile.store 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.multibindings.ClassKey 6 | import dagger.multibindings.IntoMap 7 | import frangsierra.kotlinfirechat.chat.store.SendMessageCompleteAction 8 | import frangsierra.kotlinfirechat.core.dagger.AppScope 9 | import frangsierra.kotlinfirechat.profile.controller.ProfileController 10 | import frangsierra.kotlinfirechat.profile.controller.ProfileControllerImpl 11 | import frangsierra.kotlinfirechat.session.store.CreateAccountCompleteAction 12 | import frangsierra.kotlinfirechat.session.store.LoginCompleteAction 13 | import frangsierra.kotlinfirechat.util.taskRunning 14 | import mini.Reducer 15 | import mini.Store 16 | import javax.inject.Inject 17 | 18 | @AppScope 19 | class ProfileStore @Inject constructor(val controller: ProfileController) : Store() { 20 | 21 | @Reducer 22 | fun loadAndCreateUserData(action: CreateAccountCompleteAction): ProfileState { 23 | if (!action.task.isSuccessful()) return state 24 | controller.loadUserProfile(action.user!!) //User can't be null if the request is successful 25 | return state.copy(loadProfileTask = taskRunning()) 26 | } 27 | 28 | @Reducer 29 | fun loadUserOnLogin(action: LoginCompleteAction): ProfileState { 30 | if (!action.task.isSuccessful()) return state 31 | controller.loadUserProfile(action.user!!) //User can't be null if the request is successful 32 | return state.copy(loadProfileTask = taskRunning()) 33 | } 34 | 35 | fun updateMessageCount(action: SendMessageCompleteAction): ProfileState { 36 | if (!action.task.isSuccessful()) return state 37 | return state.copy(publicProfile = state.publicProfile?.copy(totalMessages = state.publicProfile!!.totalMessages.plus(1))) 38 | } 39 | 40 | @Reducer 41 | fun userDataLoaded(action: LoadUserDataCompleteAction): ProfileState { 42 | if (!state.loadProfileTask.isRunning()) return state 43 | return state.copy(loadProfileTask = action.task, publicProfile = action.publicProfile, privateData = action.privateData) 44 | } 45 | } 46 | 47 | @Module 48 | abstract class ProfileModule { 49 | @Binds 50 | @AppScope 51 | @IntoMap 52 | @ClassKey(ProfileStore::class) 53 | abstract fun provideProfileStore(store: ProfileStore): Store<*> 54 | 55 | @Binds 56 | @AppScope 57 | abstract fun bindProfileController(impl: ProfileControllerImpl): ProfileController 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/CreateAccountActivity.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session 2 | 3 | import android.content.Intent 4 | import com.google.android.gms.auth.api.signin.GoogleSignIn 5 | import com.google.android.gms.auth.api.signin.GoogleSignInAccount 6 | import com.google.android.gms.auth.api.signin.GoogleSignInClient 7 | import com.google.android.gms.auth.api.signin.GoogleSignInOptions 8 | import com.google.android.gms.common.api.ApiException 9 | import com.google.firebase.auth.AuthCredential 10 | import frangsierra.kotlinfirechat.home.HomeActivity 11 | import frangsierra.kotlinfirechat.R 12 | import frangsierra.kotlinfirechat.core.errors.ErrorHandler 13 | import frangsierra.kotlinfirechat.core.flux.FluxActivity 14 | import frangsierra.kotlinfirechat.session.store.CreateAccountWithCredentialsAction 15 | import frangsierra.kotlinfirechat.session.store.CreateAccountWithProviderCredentialsAction 16 | import frangsierra.kotlinfirechat.session.store.SessionStore 17 | import frangsierra.kotlinfirechat.util.* 18 | import kotlinx.android.synthetic.main.create_account_activity.* 19 | import javax.inject.Inject 20 | 21 | class CreateAccountActivity : FluxActivity(), GoogleLoginCallback { 22 | 23 | @Inject 24 | lateinit var sessionStore: SessionStore 25 | @Inject 26 | lateinit var errorHandler: ErrorHandler 27 | 28 | override val googleApiClient: GoogleSignInOptions by lazy { 29 | GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) 30 | .requestIdToken(getString(R.string.default_web_client_id)) 31 | .requestEmail() 32 | .build() 33 | } 34 | 35 | override val googleSingInClient: GoogleSignInClient by lazy { GoogleSignIn.getClient(this, googleApiClient) } 36 | 37 | override fun onCreate(savedInstanceState: android.os.Bundle?) { 38 | super.onCreate(savedInstanceState) 39 | setContentView(frangsierra.kotlinfirechat.R.layout.create_account_activity) 40 | 41 | initializeInterface() 42 | } 43 | 44 | private fun initializeInterface() { 45 | createAccountButton.setOnClickListener { signInWithEmailAndPassword() } 46 | createGoogleButton.setOnClickListener { logInWithGoogle(this) } 47 | } 48 | 49 | private fun signInWithEmailAndPassword() { 50 | if (!fieldsAreFilled()) return 51 | showProgressDialog("Creating account") 52 | dispatcher.dispatch(CreateAccountWithCredentialsAction(editTextEmail.text.toString(), editTextPassword.text.toString(), editTextUsername.text.toString())) 53 | sessionStore.flowable() 54 | .filterOne { it.createAccountTask.isTerminal() } //Wait for request to finish 55 | .subscribe { 56 | if (it.createAccountTask.isSuccessful() && it.loggedUser != null) { 57 | if (it.verified) goToHome() else goToVerificationEmailScreen() 58 | } else if (it.createAccountTask.isFailure()) { 59 | it.createAccountTask.error?.let { 60 | toast(errorHandler.getMessageForError(it)) 61 | } 62 | } 63 | dismissProgressDialog() 64 | }.track() 65 | } 66 | 67 | //How to retrieve SHA1 for Firebase Google Sign In https://stackoverflow.com/questions/15727912/sha-1-fingerprint-of-keystore-certificate 68 | override fun onGoogleCredentialReceived(credential: AuthCredential, account: GoogleSignInAccount) { 69 | showProgressDialog("Creating account") 70 | dispatcher.dispatch(CreateAccountWithProviderCredentialsAction(credential, GoogleSignInApiUtils.getUserData(account))) 71 | sessionStore.flowable() 72 | .filterOne { it.createAccountTask.isTerminal() } //Wait for request to finish 73 | .subscribe { 74 | if (it.createAccountTask.isSuccessful() && it.loggedUser != null) { 75 | if (it.verified) goToHome() else goToVerificationEmailScreen() 76 | } else if (it.createAccountTask.isFailure()) { 77 | it.createAccountTask.error?.let { 78 | toast(errorHandler.getMessageForError(it)) 79 | } 80 | } 81 | dismissProgressDialog() 82 | }.track() 83 | } 84 | 85 | override fun onGoogleSignInFailed(e: ApiException) { 86 | dismissProgressDialog() 87 | toast(e.toString()) 88 | } 89 | 90 | private fun fieldsAreFilled(): Boolean { 91 | editTextUsername.text.toString().takeIf { it.isEmpty() }?.let { 92 | inputUsername.onError(getString(frangsierra.kotlinfirechat.R.string.error_cannot_be_empty)) 93 | return false 94 | } 95 | inputUsername.onError(null, false) 96 | 97 | editTextEmail.text.toString().takeIf { it.isEmpty() }?.let { 98 | inputEmail.onError(getString(frangsierra.kotlinfirechat.R.string.error_cannot_be_empty)) 99 | return false 100 | } 101 | inputEmail.onError(null, false) 102 | 103 | editTextPassword.text.toString().takeIf { it.isEmpty() }?.let { 104 | inputPassword.onError(getString(frangsierra.kotlinfirechat.R.string.error_cannot_be_empty)) 105 | return false 106 | } 107 | editTextPassword.text.toString().takeIf { it.length < 6 }?.let { 108 | inputPassword.onError(getString(frangsierra.kotlinfirechat.R.string.error_invalid_password_not_valid)) 109 | return false 110 | } 111 | inputPassword.onError(null, false) 112 | return true 113 | } 114 | 115 | private fun goToHome() { 116 | dismissProgressDialog() 117 | val intent = HomeActivity.newIntent(this).apply { 118 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 119 | } 120 | startActivity(intent) 121 | } 122 | 123 | private fun goToVerificationEmailScreen() { 124 | dismissProgressDialog() 125 | EmailVerificationActivity.startActivity(this, sessionStore.state.loggedUser!!.email) 126 | overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) 127 | } 128 | 129 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 130 | super.onActivityResult(requestCode, resultCode, data) 131 | manageGoogleResult(requestCode, data) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/LoginActivity.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import com.google.android.gms.auth.api.signin.GoogleSignIn 7 | import com.google.android.gms.auth.api.signin.GoogleSignInAccount 8 | import com.google.android.gms.auth.api.signin.GoogleSignInClient 9 | import com.google.android.gms.auth.api.signin.GoogleSignInOptions 10 | import com.google.android.gms.common.api.ApiException 11 | import com.google.firebase.auth.AuthCredential 12 | import frangsierra.kotlinfirechat.home.HomeActivity 13 | import frangsierra.kotlinfirechat.R 14 | import frangsierra.kotlinfirechat.core.errors.ErrorHandler 15 | import frangsierra.kotlinfirechat.core.flux.FluxActivity 16 | import frangsierra.kotlinfirechat.session.store.LoginWithCredentials 17 | import frangsierra.kotlinfirechat.session.store.LoginWithProviderCredentials 18 | import frangsierra.kotlinfirechat.session.store.SessionStore 19 | import frangsierra.kotlinfirechat.util.* 20 | import kotlinx.android.synthetic.main.login_activity.* 21 | import javax.inject.Inject 22 | 23 | class LoginActivity : FluxActivity(), GoogleLoginCallback { 24 | 25 | @Inject 26 | lateinit var sessionStore: SessionStore 27 | @Inject 28 | lateinit var errorHandler: ErrorHandler 29 | 30 | companion object { 31 | fun newIntent(context: Context): Intent = 32 | Intent(context, LoginActivity::class.java) 33 | } 34 | 35 | override val googleApiClient: GoogleSignInOptions by lazy { 36 | GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) 37 | .requestIdToken(getString(R.string.default_web_client_id)) 38 | .requestEmail() 39 | .build() 40 | } 41 | 42 | override val googleSingInClient: GoogleSignInClient by lazy { GoogleSignIn.getClient(this, googleApiClient) } 43 | 44 | override fun onCreate(savedInstanceState: Bundle?) { 45 | super.onCreate(savedInstanceState) 46 | setContentView(R.layout.login_activity) 47 | 48 | initializeInterface() 49 | } 50 | 51 | private fun initializeInterface() { 52 | loginPasswordButton.setOnClickListener { loginWithEmailAndPassword() } 53 | loginGoogleButton.setOnClickListener { logInWithGoogle(this) } 54 | createAccountText.setOnClickListener { 55 | startActivity(Intent(this, CreateAccountActivity::class.java)) 56 | } 57 | } 58 | 59 | private fun loginWithEmailAndPassword() { 60 | if (!fieldsAreFilled()) return 61 | showProgressDialog("Logging") 62 | dispatcher.dispatch(LoginWithCredentials(editTextEmail.text.toString(), editTextPassword.text.toString())) 63 | sessionStore.flowable() 64 | .filterOne { it.loginTask.isTerminal() } //Wait for request to finish 65 | .subscribe { 66 | if (it.loginTask.isSuccessful() && it.loggedUser != null) { 67 | if (it.verified) goToHome() else goToVerificationEmailScreen() 68 | } else if (it.loginTask.isFailure()) { 69 | it.loginTask.error?.let { 70 | toast(errorHandler.getMessageForError(it)) 71 | } 72 | } 73 | dismissProgressDialog() 74 | }.track() 75 | } 76 | 77 | //How to retrieve SHA1 for Firebase Google Sign In https://stackoverflow.com/questions/15727912/sha-1-fingerprint-of-keystore-certificate 78 | override fun onGoogleCredentialReceived(credential: AuthCredential, account: GoogleSignInAccount) { 79 | showProgressDialog("Logging") 80 | dispatcher.dispatch(LoginWithProviderCredentials(credential, account.email!!)) 81 | sessionStore.flowable() 82 | .filterOne { it.loginTask.isTerminal() } //Wait for request to finish 83 | .subscribe { 84 | if (it.loginTask.isSuccessful() && it.loggedUser != null) { 85 | if (it.verified) goToHome() else goToVerificationEmailScreen() 86 | } else if (it.loginTask.isFailure()) { 87 | it.loginTask.error?.let { 88 | toast(errorHandler.getMessageForError(it)) 89 | } 90 | } 91 | dismissProgressDialog() 92 | }.track() 93 | } 94 | 95 | override fun onGoogleSignInFailed(e: ApiException) { 96 | dismissProgressDialog() 97 | toast(e.toString()) 98 | } 99 | 100 | private fun fieldsAreFilled(): Boolean { 101 | editTextEmail.text.toString().takeIf { it.isEmpty() }?.let { 102 | inputEmail.onError(getString(R.string.error_cannot_be_empty)) 103 | return false 104 | } 105 | inputEmail.onError(null, false) 106 | editTextPassword.text.toString().takeIf { it.isEmpty() }?.let { 107 | inputPassword.onError(getString(R.string.error_cannot_be_empty)) 108 | return false 109 | } 110 | inputPassword.onError(null, false) 111 | return true 112 | } 113 | 114 | private fun goToHome() { 115 | dismissProgressDialog() 116 | val intent = HomeActivity.newIntent(this).apply { 117 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 118 | } 119 | startActivity(intent) 120 | } 121 | 122 | private fun goToVerificationEmailScreen() { 123 | dismissProgressDialog() 124 | EmailVerificationActivity.startActivity(this, sessionStore.state.loggedUser!!.email) 125 | overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) 126 | } 127 | 128 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 129 | super.onActivityResult(requestCode, resultCode, data) 130 | manageGoogleResult(requestCode, data) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/VerifyEmailActivity.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import frangsierra.kotlinfirechat.home.HomeActivity 7 | import frangsierra.kotlinfirechat.R 8 | import frangsierra.kotlinfirechat.core.errors.ErrorHandler 9 | import frangsierra.kotlinfirechat.core.flux.FluxActivity 10 | import frangsierra.kotlinfirechat.session.store.SendVerificationEmailAction 11 | import frangsierra.kotlinfirechat.session.store.SessionStore 12 | import frangsierra.kotlinfirechat.session.store.SignOutAction 13 | import frangsierra.kotlinfirechat.session.store.VerifyUserEmailAction 14 | import frangsierra.kotlinfirechat.util.dismissProgressDialog 15 | import frangsierra.kotlinfirechat.util.filterOne 16 | import frangsierra.kotlinfirechat.util.showProgressDialog 17 | import frangsierra.kotlinfirechat.util.toast 18 | import kotlinx.android.synthetic.main.verification_email_activity.* 19 | import javax.inject.Inject 20 | 21 | class EmailVerificationActivity : FluxActivity() { 22 | 23 | @Inject 24 | lateinit var sessionStore: SessionStore 25 | 26 | @Inject 27 | lateinit var errorHandler: ErrorHandler 28 | 29 | private lateinit var verificationEmail: String 30 | 31 | companion object { 32 | val VERIFICATION_EMAIL = "verification_email" 33 | fun startActivity(context: Context, email: String) { 34 | val intent = Intent(context, EmailVerificationActivity::class.java) 35 | .apply { 36 | putExtra(VERIFICATION_EMAIL, email) 37 | } 38 | context.startActivity(intent) 39 | } 40 | } 41 | 42 | override fun onCreate(savedInstanceState: Bundle?) { 43 | super.onCreate(savedInstanceState) 44 | setContentView(R.layout.verification_email_activity) 45 | if (!intent.hasExtra(VERIFICATION_EMAIL)) { 46 | throw IllegalAccessException("verification email needed") 47 | } 48 | verificationEmail = intent.getStringExtra(VERIFICATION_EMAIL) 49 | initializeInterface() 50 | } 51 | 52 | private fun initializeInterface() { 53 | confirm_email_next_button.setOnClickListener { verifyUserEmail() } 54 | confirm_email_re_send_button.setOnClickListener { sendVerificationEmailAction() } 55 | } 56 | 57 | private fun verifyUserEmail() { 58 | confirm_email_next_button.isEnabled = false 59 | showProgressDialog("Verifying") 60 | dispatcher.dispatchOnUi(VerifyUserEmailAction()) 61 | sessionStore.flowable() 62 | .filterOne { it.verifyUserTask.isTerminal() } //Wait for request to finish 63 | .subscribe { 64 | if (it.verifyUserTask.isSuccessful() && it.loggedUser != null && it.verified) { 65 | goHome() 66 | } else { 67 | toast("Your account hasn´t been confirmed yet") 68 | } 69 | confirm_email_next_button.isEnabled = true 70 | dismissProgressDialog() 71 | }.track() 72 | } 73 | 74 | private fun sendVerificationEmailAction() { 75 | showProgressDialog("Sending notification email") 76 | dispatcher.dispatch(SendVerificationEmailAction()) 77 | sessionStore.flowable() 78 | .filterOne { it.verificationEmailTask.isTerminal() } //Wait for request to finish 79 | .subscribe { 80 | if (it.verificationEmailTask.isSuccessful()) { 81 | toast("We have sent a verification email to your address") 82 | } else { 83 | it.verificationEmailTask.error?.let { 84 | toast(errorHandler.getMessageForError(it)) 85 | } 86 | } 87 | dismissProgressDialog() 88 | }.track() 89 | } 90 | 91 | private fun goHome() { 92 | dismissProgressDialog() 93 | val intent = HomeActivity.newIntent(this).apply { 94 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 95 | } 96 | startActivity(intent) 97 | } 98 | 99 | override fun onBackPressed() { 100 | dispatcher.dispatchOnUi(SignOutAction()) 101 | } 102 | 103 | override fun finish() { 104 | super.finish() 105 | overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) 106 | } 107 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/controller/SessionController.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session.controller 2 | 3 | import com.google.android.gms.tasks.Tasks 4 | import com.google.firebase.auth.* 5 | import frangsierra.kotlinfirechat.core.dagger.AppScope 6 | import frangsierra.kotlinfirechat.core.flux.doAsync 7 | import frangsierra.kotlinfirechat.session.model.* 8 | import frangsierra.kotlinfirechat.session.store.CreateAccountCompleteAction 9 | import frangsierra.kotlinfirechat.session.store.LoginCompleteAction 10 | import frangsierra.kotlinfirechat.session.store.VerificationEmailSentAction 11 | import frangsierra.kotlinfirechat.session.store.VerifyUserEmailCompleteAction 12 | import frangsierra.kotlinfirechat.util.taskFailure 13 | import frangsierra.kotlinfirechat.util.taskSuccess 14 | import mini.Dispatcher 15 | import javax.inject.Inject 16 | 17 | interface SessionController { 18 | 19 | /** 20 | * To control the splash, when the app is launched try to make a first login. 21 | */ 22 | fun tryToLoginInFirstInstance() 23 | 24 | /** 25 | * Try to signIn in the Firebase Auth API with a email and a password, returning an [AuthResult] 26 | * if it's successful or a [FirebaseAuthException] if fails. 27 | * 28 | * @param email email of the user 29 | * @param password password introduced 30 | */ 31 | fun loginWithCredentials(email: String, password: String) 32 | 33 | /** 34 | * Call to the server to create a new user account using a email and a password. 35 | * 36 | * @param email email of the user 37 | * @param password password introduced 38 | * @param username username for the user 39 | */ 40 | fun createAccountWithCredentials(email: String, password: String, username: String) 41 | 42 | /** 43 | * Try to signIn in the Firebase Auth API with a credential, returning an [AuthResult] 44 | * if it's successful, a [ProviderNotLinkedException] if the user is trying to login without register first 45 | * the credential, or an [FirebaseAuthException] if fails. 46 | * 47 | * @param credential the [AuthCredential] with all the info of the provider 48 | * @param email email of the user 49 | */ 50 | fun loginWithProviderCredentials(credential: AuthCredential, email: String) 51 | 52 | /** 53 | * Call to the server to create a new user account using a [AuthCredential] from a provider. 54 | * 55 | * @param credential the [AuthCredential] with all the info of the provider 56 | * @param userData data of the user 57 | */ 58 | fun createAccountWithProviderCredentials(credential: AuthCredential, userData: User) 59 | 60 | /** 61 | * Send a verification email to the user logged in the auth instance. 62 | */ 63 | fun sendVerificationEmail() 64 | 65 | /** 66 | * Sign out the firebase auth instance. 67 | */ 68 | fun signOut() 69 | 70 | /** 71 | * Refresh the instance of the [FirebaseUser] with all the updated data from the server to verify the email. 72 | */ 73 | fun verifyUser() 74 | } 75 | 76 | @AppScope 77 | class SessionControllerImpl @Inject constructor(private val authInstance: FirebaseAuth, 78 | private val dispatcher: Dispatcher) : SessionController { 79 | override fun tryToLoginInFirstInstance() { 80 | authInstance.addAuthStateListener { firebaseAuth -> 81 | doAsync { 82 | if (firebaseAuth.currentUser == null || !firebaseAuth.currentUser!!.isEmailVerified) 83 | dispatcher.dispatchOnUi(LoginCompleteAction(task = taskFailure())) 84 | else { 85 | val currentUser = firebaseAuth.currentUser!! 86 | dispatcher.dispatchOnUi(LoginCompleteAction(task = taskSuccess(), 87 | user = currentUser.toUser(), 88 | associatedProviders = currentUser.associatedProviders())) 89 | 90 | } 91 | } 92 | } 93 | } 94 | 95 | override fun loginWithCredentials(email: String, password: String) { 96 | doAsync { 97 | try { 98 | val result = Tasks.await(authInstance.signInWithEmailAndPassword(email, password)) 99 | val emailVerified = result.user.isEmailVerified 100 | val user = result.user.toUser() 101 | val providers = result.user.associatedProviders() 102 | dispatcher.dispatchOnUi(LoginCompleteAction( 103 | user = user, 104 | emailVerified = emailVerified, 105 | task = taskSuccess(), 106 | associatedProviders = providers)) 107 | } catch (e: Throwable) { 108 | dispatcher.dispatchOnUi(LoginCompleteAction(user = null, task = taskFailure(e))) 109 | } 110 | } 111 | } 112 | 113 | override fun createAccountWithCredentials(email: String, password: String, username: String) { 114 | doAsync { 115 | try { 116 | val result = Tasks.await(authInstance.createUserWithEmailAndPassword(email, password)) 117 | val firebaseUser = result.user 118 | val emailVerified = firebaseUser.isEmailVerified 119 | val user = firebaseUser.toUser().copy(username = username) 120 | val providers = firebaseUser.associatedProviders() 121 | dispatcher.dispatchOnUi(CreateAccountCompleteAction( 122 | user = user, 123 | emailVerified = emailVerified, 124 | task = taskSuccess(), 125 | associatedProviders = providers)) 126 | if (!emailVerified) sendVerificationEmailToUser(firebaseUser) 127 | } catch (e: Throwable) { 128 | dispatcher.dispatchOnUi(LoginCompleteAction(user = null, task = taskFailure(e))) 129 | } 130 | } 131 | } 132 | 133 | override fun loginWithProviderCredentials(credential: AuthCredential, email: String) { 134 | doAsync { 135 | try { 136 | val providerResults = Tasks.await(authInstance.fetchSignInMethodsForEmail(email)) 137 | val isNewAccount = providerResults.signInMethods!!.contains((credential.provider)) 138 | if (isNewAccount) { 139 | val authResult = Tasks.await(authInstance.signInWithCredential(credential)) 140 | val emailVerified = authResult.user.isEmailVerified 141 | val user = authResult.user.toUser() 142 | val providers = authResult.user.associatedProviders() 143 | dispatcher.dispatchOnUi(LoginCompleteAction( 144 | user = user, 145 | emailVerified = emailVerified, 146 | task = taskSuccess(), 147 | associatedProviders = providers)) 148 | if (!emailVerified) sendVerificationEmailToUser(authResult.user) 149 | } else { 150 | dispatcher.dispatchOnUi(LoginCompleteAction(user = null, task = taskFailure(ProviderNotLinkedException(credential.provider)))) 151 | } 152 | } catch (e: Throwable) { 153 | dispatcher.dispatchOnUi(LoginCompleteAction(user = null, task = taskFailure(e))) 154 | } 155 | } 156 | } 157 | 158 | override fun createAccountWithProviderCredentials(credential: AuthCredential, userData: User) { 159 | doAsync { 160 | try { 161 | val providerResults = Tasks.await(authInstance.fetchSignInMethodsForEmail(userData.email)) 162 | val isNewAccount = providerResults.signInMethods!!.contains((credential.provider)) 163 | if (isNewAccount) { 164 | val authResult = Tasks.await(authInstance.signInWithCredential(credential)) 165 | val emailVerified = authResult.user.isEmailVerified 166 | val user = authResult.user.toUser().copy(photoUrl = userData.photoUrl) 167 | val providers = authResult.user.associatedProviders() 168 | dispatcher.dispatchOnUi(CreateAccountCompleteAction( 169 | user = user, 170 | alreadyExisted = !isNewAccount, 171 | emailVerified = emailVerified, 172 | task = taskSuccess(), 173 | associatedProviders = providers)) 174 | if (!emailVerified) sendVerificationEmailToUser(authResult.user) 175 | } else { 176 | dispatcher.dispatchOnUi(CreateAccountCompleteAction(user = null, task = taskFailure(ProviderNotLinkedException(credential.provider)))) 177 | } 178 | } catch (e: Throwable) { 179 | dispatcher.dispatchOnUi(CreateAccountCompleteAction(user = null, task = taskFailure(e))) 180 | } 181 | } 182 | } 183 | 184 | override fun sendVerificationEmail() { 185 | if (authInstance.currentUser == null) { 186 | dispatcher.dispatchOnUi(VerificationEmailSentAction(taskFailure(FirebaseUserNotFound()))) 187 | return 188 | } 189 | sendVerificationEmailToUser(authInstance.currentUser!!) 190 | } 191 | 192 | override fun signOut() { 193 | authInstance.signOut() 194 | } 195 | 196 | override fun verifyUser() { 197 | if (authInstance.currentUser == null) { 198 | dispatcher.dispatchOnUi(VerifyUserEmailCompleteAction(taskFailure(FirebaseUserNotFound()), 199 | verified = false)) 200 | return 201 | } 202 | 203 | authInstance.currentUser!!.reload() //send a verification email needs to have a recent user instance. We need to reload it to avoid errors 204 | .addOnCompleteListener { reloadTask -> 205 | //After the reload the currentUser can be null because it takes some time in be updated 206 | if (reloadTask.isSuccessful && authInstance.currentUser != null) { 207 | val user = authInstance.currentUser!! 208 | dispatcher.dispatchOnUi(VerifyUserEmailCompleteAction(task = taskSuccess(), verified = user.isEmailVerified)) 209 | } else dispatcher.dispatchOnUi(VerifyUserEmailCompleteAction(task = taskFailure(reloadTask.exception))) 210 | } 211 | } 212 | 213 | private fun sendVerificationEmailToUser(user: FirebaseUser) { 214 | doAsync { 215 | try { 216 | Tasks.await(user.reload()) 217 | Tasks.await(user.sendEmailVerification()) 218 | dispatcher.dispatchOnUi(VerificationEmailSentAction(taskSuccess())) 219 | } catch (e: Throwable) { 220 | dispatcher.dispatchOnUi(VerificationEmailSentAction(task = taskFailure(e))) 221 | } 222 | } 223 | } 224 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/model/LoginProvider.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session.model 2 | 3 | /** 4 | * Represent the possible providers used in the application. 5 | */ 6 | enum class LoginProvider(private val providerName: String) { 7 | FIREBASE("firebase"), 8 | PASSWORD("password"), 9 | GOOGLE("google.com"); 10 | 11 | companion object { 12 | fun withValue(providerName: String) = LoginProvider.values().first { it.providerName == providerName } 13 | } 14 | 15 | fun value(): String = this.providerName 16 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/model/SessionExceptions.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session.model 2 | 3 | class FirebaseUserNotFound : Exception() { 4 | override val message: String = "There is no authenticated user connected to this auth instance" 5 | } 6 | 7 | class ProviderNotLinkedException(provider: String) : Exception() { 8 | override val message: String = "The provider $provider is not linked to the given account" 9 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/model/User.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session.model 2 | 3 | import com.google.firebase.auth.FirebaseUser 4 | import com.google.firebase.firestore.ServerTimestamp 5 | import java.util.* 6 | 7 | data class User(val uid: String, 8 | val username: String, 9 | val photoUrl: String?, 10 | val email: String) 11 | 12 | fun FirebaseUser.toUser(): User = User( 13 | uid = uid, 14 | username = displayName ?: "Anonymous", 15 | photoUrl = photoUrl?.toString(), 16 | email = email!!) 17 | 18 | fun FirebaseUser.associatedProviders(): List = 19 | providerData.mapNotNull { userInfo -> 20 | LoginProvider.values().firstOrNull { it.value() == userInfo.providerId } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/store/SessionActions.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session.store 2 | 3 | import com.google.firebase.auth.AuthCredential 4 | import frangsierra.kotlinfirechat.session.model.LoginProvider 5 | import frangsierra.kotlinfirechat.session.model.User 6 | import frangsierra.kotlinfirechat.util.Task 7 | import mini.Action 8 | 9 | /** 10 | * Action dispatched to sign out the current account and reset all the data. 11 | */ 12 | class SignOutAction : Action 13 | 14 | /** 15 | * Action dispatched to refresh the user data. 16 | */ 17 | class RefreshUserAction : Action 18 | 19 | /** 20 | * Action dispatched when the user token is refresheed. 21 | */ 22 | class UserRefreshedCompleteAction(val task: Task, 23 | val user: User?, 24 | val associatedProviders: List = listOf()) : Action 25 | 26 | /** 27 | * Action dispatched to start the process for verify the email of the current user. 28 | */ 29 | class VerifyUserEmailAction : Action 30 | 31 | /** 32 | * Action dispatched when the verification refresh is complete. 33 | */ 34 | class VerifyUserEmailCompleteAction(val task: Task, 35 | val verified: Boolean = false) : Action 36 | 37 | /** 38 | * Action dispatched to reset the password of the current user. 39 | */ 40 | data class ResetPasswordAction(val email: String) : Action 41 | 42 | /** 43 | * Action dispatched when the reset password email as been sent. 44 | */ 45 | data class ResetPasswordEmailSentAction(val task: Task) : Action 46 | 47 | /** 48 | * Action dispatched to send a verification email when a new account is created or a 49 | * email provider is attached to an user. 50 | */ 51 | class SendVerificationEmailAction : Action 52 | 53 | /** 54 | * Action dispatched when the verification email has been sent. 55 | */ 56 | data class VerificationEmailSentAction(val task: Task) : Action 57 | 58 | /** 59 | * Action dispatched on Appstart to try to log the user if the credentials still on the cache. 60 | */ 61 | class TryToLoginInFirstInstanceAction : Action 62 | 63 | /** 64 | * Action dispatched to login in the app with an email and a password. 65 | */ 66 | data class LoginWithCredentials(val email: String, val password: String) : Action 67 | 68 | /** 69 | * Action dispatched to login with an external provider credential. 70 | */ 71 | data class LoginWithProviderCredentials(val credential: AuthCredential, 72 | val email: String) : Action 73 | 74 | /** 75 | * Action dispatched when login process as finished. 76 | */ 77 | data class LoginCompleteAction(val user: User? = null, 78 | val emailVerified: Boolean = false, 79 | val task: Task, 80 | val associatedProviders: List = listOf()) : Action 81 | 82 | /** 83 | * Action dispatched to create an account with an user, password and a new username. 84 | */ 85 | data class CreateAccountWithCredentialsAction(val email: String, 86 | val password: String, 87 | val username: String) : Action 88 | 89 | /** 90 | * Action dispatched to create an account with an external provider credential. 91 | */ 92 | data class CreateAccountWithProviderCredentialsAction(val credential: AuthCredential, 93 | val user: User) : Action 94 | 95 | /** 96 | * Action dispatched when the create account process as finished. 97 | */ 98 | data class CreateAccountCompleteAction(val user: User?, 99 | val alreadyExisted: Boolean = false, 100 | val emailVerified: Boolean = false, 101 | val task: Task, 102 | val associatedProviders: List = listOf()) : Action -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/store/SessionState.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session.store 2 | 3 | import frangsierra.kotlinfirechat.session.model.LoginProvider 4 | import frangsierra.kotlinfirechat.session.model.User 5 | import frangsierra.kotlinfirechat.util.Task 6 | 7 | data class SessionState(val verified: Boolean = false, 8 | val createAccountTask: Task = Task(), 9 | val loginTask: Task = Task(), 10 | val verifyUserTask: Task = Task(), 11 | val verificationEmailTask: Task = Task(), 12 | val refreshUserTask: Task = Task(), 13 | val providers: List = listOf(), 14 | val loggedUser: User? = null) -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/session/store/SessionStore.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.session.store 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.multibindings.ClassKey 6 | import dagger.multibindings.IntoMap 7 | import frangsierra.kotlinfirechat.core.dagger.AppScope 8 | import frangsierra.kotlinfirechat.core.flux.prefs 9 | import frangsierra.kotlinfirechat.session.controller.SessionController 10 | import frangsierra.kotlinfirechat.session.controller.SessionControllerImpl 11 | import frangsierra.kotlinfirechat.util.taskRunning 12 | import mini.Reducer 13 | import mini.Store 14 | import javax.inject.Inject 15 | 16 | @AppScope 17 | class SessionStore @Inject constructor(val controller: SessionController) : Store() { 18 | 19 | @Reducer 20 | fun loginWithCredentials(action: LoginWithCredentials): SessionState { 21 | if (state.loginTask.isRunning()) return state 22 | controller.loginWithCredentials(action.email, action.password) 23 | return state.copy(loginTask = taskRunning()) 24 | } 25 | 26 | @Reducer 27 | fun loginWithProvider(action: LoginWithProviderCredentials): SessionState { 28 | if (state.loginTask.isRunning()) return state 29 | controller.loginWithProviderCredentials(action.credential, action.email) 30 | return state.copy(loginTask = taskRunning()) 31 | } 32 | 33 | @Reducer(priority = 10) 34 | fun loginComplete(action: LoginCompleteAction): SessionState { 35 | if (!state.loginTask.isRunning()) return state 36 | prefs.loggedUserId = action.user?.uid //Update the shared prefs of the current user 37 | prefs.loggedUsername = action.user?.username //Update the shared prefs of the current user 38 | return state.copy( 39 | loggedUser = action.user, 40 | loginTask = action.task, 41 | verified = action.emailVerified, 42 | providers = action.associatedProviders) 43 | } 44 | 45 | @Reducer 46 | fun createAccountWithCredentials(action: CreateAccountWithCredentialsAction): SessionState { 47 | if (state.createAccountTask.isRunning()) return state 48 | controller.createAccountWithCredentials(action.email, action.password, action.username) 49 | return state.copy(createAccountTask = taskRunning()) 50 | } 51 | 52 | @Reducer 53 | fun createAccountWithProvider(action: CreateAccountWithProviderCredentialsAction): SessionState { 54 | if (state.createAccountTask.isRunning()) return state 55 | controller.createAccountWithProviderCredentials(action.credential, action.user) 56 | return state.copy(createAccountTask = taskRunning()) 57 | } 58 | 59 | @Reducer 60 | fun createAccountComplete(action: CreateAccountCompleteAction): SessionState { 61 | if (!state.createAccountTask.isRunning()) return state 62 | if (action.task.isSuccessful()) { 63 | prefs.loggedUserId = action.user!!.uid //Update the shared prefs of the current user 64 | prefs.loggedUsername = action.user.username //Update the shared prefs of the current user 65 | } 66 | return state.copy(loggedUser = action.user, createAccountTask = action.task, 67 | verified = action.emailVerified, providers = action.associatedProviders) 68 | } 69 | 70 | @Reducer 71 | fun verifyUserEmail(action: VerifyUserEmailAction): SessionState { 72 | if (state.verifyUserTask.isRunning()) return state 73 | controller.verifyUser() 74 | return state.copy(verifyUserTask = taskRunning()) 75 | } 76 | 77 | @Reducer 78 | fun userEmailVerified(action: VerifyUserEmailCompleteAction): SessionState { 79 | if (!state.verifyUserTask.isRunning()) return state 80 | return state.copy(verifyUserTask = action.task, verified = action.verified) 81 | } 82 | 83 | @Reducer 84 | fun sendVerificationEmail(action: SendVerificationEmailAction): SessionState { 85 | if (state.verificationEmailTask.isRunning()) return state 86 | controller.sendVerificationEmail() 87 | return state.copy(verificationEmailTask = taskRunning()) 88 | } 89 | 90 | @Reducer 91 | fun verificationEmailSent(action: VerificationEmailSentAction): SessionState { 92 | if (!state.verificationEmailTask.isRunning()) return state 93 | return state.copy(verificationEmailTask = action.task) 94 | } 95 | 96 | @Reducer 97 | fun tryToLoginOnFirstInstance(action: TryToLoginInFirstInstanceAction): SessionState { 98 | if (state.loginTask.isRunning()) return state 99 | controller.tryToLoginInFirstInstance() 100 | return state.copy(loginTask = taskRunning()) 101 | } 102 | 103 | @Reducer 104 | fun signOut(action: SignOutAction): SessionState { 105 | controller.signOut() 106 | prefs.loggedUserId = null //Update the shared prefs of the current user 107 | prefs.loggedUsername = null //Update the shared prefs of the current user 108 | return initialState() 109 | } 110 | } 111 | 112 | @Module 113 | abstract class SessionModule { 114 | @Binds 115 | @AppScope 116 | @IntoMap 117 | @ClassKey(SessionStore::class) 118 | abstract fun provideSessionStore(store: SessionStore): Store<*> 119 | 120 | @Binds 121 | @AppScope 122 | abstract fun bindSessionController(impl: SessionControllerImpl): SessionController 123 | } 124 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/AlertDialogExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2017 Mundo Reader S.L. 3 | * All Rights Reserved. 4 | * Confidential and Proprietary - Mundo Reader S.L. 5 | */ 6 | 7 | package frangsierra.kotlinfirechat.util 8 | 9 | import android.content.DialogInterface 10 | import android.support.annotation.StringRes 11 | import android.support.v7.app.AlertDialog 12 | import android.view.View 13 | 14 | 15 | /** 16 | * Generate a custom dialog with the given values. It works with resources ids or strings. 17 | * @param title value for the dialog title. 18 | * @param titleId resource id for the dialog title. 19 | * @param content value for the dialog content. 20 | * @param contentId resource id for the dialog content. 21 | * @param view custom view to be added to the dialog. 22 | * @param positiveButton value for the dialog positive button text. 23 | * @param positiveButtonId resource id for the dialog positive button text. 24 | * @param positiveFunction function invoked when the user click in the positive button. 25 | * @param negativeButton value for the dialog negative button text. 26 | * @param negativeButtonId resource id for the dialog negative button text. 27 | * @param negativeFunction function invoked when the user click in the negative button. 28 | * 29 | * @throws IllegalArgumentException if and resource id and a String for the same value are used. 30 | */ 31 | @Suppress("LongParameterList") 32 | fun AlertDialog.Builder.createCustomAlertDialog(@StringRes titleId: Int? = null, title: String? = null, 33 | @StringRes contentId: Int? = null, content: String? = null, 34 | @StringRes positiveButtonId: Int? = null, positiveButton: String? = null, 35 | @StringRes negativeButtonId: Int? = null, negativeButton: String? = null, 36 | @StringRes neutralButtonId: Int? = null, neutralButton: String? = null, 37 | view: View? = null, 38 | positiveFunction: (dialog: DialogInterface?) -> Unit = {}, 39 | negativeFunction: (dialog: DialogInterface?) -> Unit = { it!!.dismiss() }, 40 | neutralFunction: (dialog: DialogInterface?) -> Unit = {}, 41 | cancelable: Boolean = true): AlertDialog { 42 | 43 | // Check if there is not duplicated values 44 | if (titleId != null && title != null) 45 | throw IllegalArgumentException("Can't use two different sources for title") 46 | if (contentId != null && content != null) 47 | throw IllegalArgumentException("Can't use two different sources for content") 48 | if (positiveButtonId != null && positiveButton != null) 49 | throw IllegalArgumentException("Can't use two different sources for positive button") 50 | if (negativeButtonId != null && negativeButton != null) 51 | throw IllegalArgumentException("Can't use two different sources for negative button") 52 | if (neutralButtonId != null && neutralButton != null) 53 | throw IllegalArgumentException("Can't use two different sources for neutral button") 54 | // Set up the input title 55 | titleId?.let { setTitle(it) } 56 | title?.let { setTitle(it) } 57 | // Set up content 58 | content?.let { setMessage(it) } 59 | contentId?.let { setMessage(it) } 60 | // Set up view 61 | view?.let { setView(it) } 62 | // Set up the buttons 63 | positiveButton?.let { setPositiveButton(it, { dialog, _ -> positiveFunction.invoke(dialog) }) } 64 | positiveButtonId?.let { setPositiveButton(it, { dialog, _ -> positiveFunction.invoke(dialog) }) } 65 | negativeButton?.let { setNegativeButton(it) { dialog, _ -> negativeFunction.invoke(dialog) } } 66 | negativeButtonId?.let { setNegativeButton(it) { dialog, _ -> negativeFunction.invoke(dialog) } } 67 | neutralButton?.let { setNeutralButton(it) { dialog, _ -> neutralFunction.invoke(dialog) } } 68 | neutralButtonId?.let { setNeutralButton(it) { dialog, _ -> neutralFunction.invoke(dialog) } } 69 | setCancelable(cancelable) 70 | return create() 71 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/AndroidUtils.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.DialogInterface 6 | import android.content.Intent 7 | import android.net.Uri 8 | import android.os.Environment 9 | import android.provider.MediaStore 10 | import android.support.v4.app.Fragment 11 | import android.support.v4.content.FileProvider 12 | import android.support.v7.app.AlertDialog 13 | import frangsierra.kotlinfirechat.R 14 | import java.io.File 15 | 16 | object AndroidUtils { 17 | private val INTENT_TARGET_TYPE: String = "image/*" 18 | private val APP_IMAGE_FOLDER_PATH: String = "kotlin_chat/" 19 | val TC_REQUEST_GALLERY: Int = 101 20 | val TC_REQUEST_CAMERA: Int = 102 21 | val FORMAT_JPG = ".jpg" 22 | 23 | /** 24 | * Create an alertdialog which allows the user to choose between pick an image from the gallery or 25 | * take a new one with his camera. 26 | */ 27 | fun showImageIntentDialog(activity: Activity, outputFileUri: Uri) { 28 | val builder: AlertDialog.Builder = AlertDialog.Builder(activity) 29 | builder.setTitle(activity.getString(R.string.choose_image_picker_text)) 30 | builder.setItems(arrayOf(activity.getString(R.string.gallery_text), activity.getString(R.string.camera_text))) { _, which -> 31 | when (which) { 32 | 0 -> { 33 | // GET IMAGE FROM THE GALLERY 34 | val chooser = Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply { 35 | type = INTENT_TARGET_TYPE 36 | }, activity.getString(R.string.choose_image_picture_text)) 37 | activity.startActivityForResult(chooser, TC_REQUEST_GALLERY) 38 | } 39 | 1 -> { 40 | val cameraFolder: File = if (Environment.getExternalStorageState() == android.os.Environment.MEDIA_MOUNTED) 41 | File(Environment.getExternalStorageDirectory(), APP_IMAGE_FOLDER_PATH) 42 | else activity.cacheDir 43 | 44 | if (!cameraFolder.exists()) 45 | cameraFolder.mkdirs() 46 | 47 | val getCameraImage = Intent(MediaStore.ACTION_IMAGE_CAPTURE) 48 | getCameraImage.putExtra(MediaStore.EXTRA_OUTPUT, outputFileUri) 49 | 50 | activity.startActivityForResult(getCameraImage, TC_REQUEST_CAMERA) 51 | } 52 | } 53 | } 54 | builder.show() 55 | } 56 | 57 | /** 58 | * Create an alertdialog which allows the user to choose between pick an image from the gallery or 59 | * take a new one with his camera. 60 | */ 61 | fun showImageIntentDialogFromFragment(fragment: Fragment, outputFileUri: Uri, cancelListener: (DialogInterface) -> Unit = {}) { 62 | val builder: AlertDialog.Builder = AlertDialog.Builder(fragment.activity!!) 63 | builder.setTitle(fragment.getString(R.string.choose_image_picker_text)) 64 | builder.setOnCancelListener { cancelListener(it) } 65 | builder.setCancelable(true) 66 | builder.setItems(arrayOf(fragment.getString(R.string.gallery_text), fragment.getString(R.string.camera_text))) { _, which -> 67 | when (which) { 68 | 0 -> { 69 | // GET IMAGE FROM THE GALLERY 70 | val chooser = Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply { 71 | type = INTENT_TARGET_TYPE 72 | }, fragment.getString(R.string.choose_image_picture_text)) 73 | fragment.startActivityForResult(chooser, TC_REQUEST_GALLERY) 74 | } 75 | 1 -> { 76 | val cameraFolder: File = if (Environment.getExternalStorageState() == android.os.Environment.MEDIA_MOUNTED) 77 | File(Environment.getExternalStorageDirectory(), APP_IMAGE_FOLDER_PATH) 78 | else fragment.activity!!.cacheDir 79 | 80 | if (!cameraFolder.exists()) 81 | cameraFolder.mkdirs() 82 | 83 | val getCameraImage = Intent(MediaStore.ACTION_IMAGE_CAPTURE) 84 | getCameraImage.putExtra(MediaStore.EXTRA_OUTPUT, outputFileUri) 85 | 86 | fragment.startActivityForResult(getCameraImage, TC_REQUEST_CAMERA) 87 | } 88 | } 89 | } 90 | builder.show() 91 | } 92 | 93 | fun generateUniqueFireUri(context: Context): Uri { 94 | // Determine Uri of camera image to save. 95 | val root = File(context.filesDir, "firechat") 96 | root.mkdirs() 97 | val fileName = "${System.currentTimeMillis()}$FORMAT_JPG" 98 | val newFile = File(root, fileName) 99 | return FileProvider.getUriForFile(context, "frangsierra.kotlinfirechat.fileprovider", newFile) 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/GlideExtensions.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import android.graphics.drawable.Drawable 4 | import android.support.annotation.DrawableRes 5 | import android.widget.ImageView 6 | import com.bumptech.glide.Glide 7 | import com.bumptech.glide.load.DataSource 8 | import com.bumptech.glide.load.engine.GlideException 9 | import com.bumptech.glide.request.RequestListener 10 | import com.bumptech.glide.request.RequestOptions 11 | import com.bumptech.glide.request.target.Target 12 | 13 | 14 | fun ImageView.setCircularImage(url: String?) { 15 | val requestOptions = RequestOptions 16 | .circleCropTransform() 17 | .override(width, height) 18 | 19 | Glide.with(context) 20 | .asDrawable() 21 | .apply(requestOptions) 22 | .into(this) 23 | .waitForLayout() 24 | .clearOnDetach() 25 | } 26 | 27 | fun ImageView.setCircularImage(@DrawableRes drawableRes: Int) { 28 | val requestOptions = RequestOptions 29 | .circleCropTransform() 30 | .override(width, height) 31 | 32 | Glide.with(context) 33 | .asDrawable() 34 | .load(drawableRes) 35 | .apply(requestOptions) 36 | .into(this) 37 | .waitForLayout() 38 | .clearOnDetach() 39 | } 40 | 41 | fun ImageView.setThumnailImage(url: String) { 42 | val requestOptions = RequestOptions 43 | .centerCropTransform() 44 | .override(width, height) 45 | 46 | Glide.with(context) 47 | .asDrawable() 48 | .load(url) 49 | .apply(requestOptions) 50 | .into(this) 51 | .waitForLayout() 52 | .clearOnDetach() 53 | } 54 | 55 | fun ImageView.setImage(url: String?, onLoadError: () -> Unit = {}) { 56 | val requestOptions = RequestOptions 57 | .centerCropTransform() 58 | .override(width, height) 59 | 60 | Glide.with(context) 61 | .load(url) 62 | .apply(requestOptions) 63 | .listener(object : RequestListener { 64 | override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { 65 | onLoadError() 66 | return true 67 | } 68 | 69 | override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean = false 70 | }) 71 | .thumbnail(0.1f) 72 | .into(this) 73 | .waitForLayout() 74 | .clearOnDetach() 75 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/GoogleLoginCallback.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import com.google.android.gms.auth.api.signin.GoogleSignIn 6 | import com.google.android.gms.auth.api.signin.GoogleSignInAccount 7 | import com.google.android.gms.auth.api.signin.GoogleSignInClient 8 | import com.google.android.gms.auth.api.signin.GoogleSignInOptions 9 | import com.google.android.gms.common.api.ApiException 10 | import com.google.firebase.auth.AuthCredential 11 | import com.google.firebase.auth.GoogleAuthProvider 12 | import mini.Grove 13 | 14 | const val RC_SIGN_IN = 1001 15 | 16 | interface GoogleLoginCallback { 17 | val googleApiClient: GoogleSignInOptions 18 | val googleSingInClient: GoogleSignInClient 19 | 20 | fun logInWithGoogle(activity: Activity) { 21 | activity.startActivityForResult(googleSingInClient.signInIntent, RC_SIGN_IN) 22 | } 23 | 24 | fun manageGoogleResult(requestCode: Int, data: Intent?) { 25 | // Result returned from launching the Intent from GoogleSignInApi.getSignInIntent(...); 26 | if (requestCode == RC_SIGN_IN) { 27 | val task = GoogleSignIn.getSignedInAccountFromIntent(data) 28 | try { 29 | val account = task.getResult(ApiException::class.java) 30 | val credential = GoogleAuthProvider.getCredential(account.idToken, null) 31 | onGoogleCredentialReceived(credential, account) 32 | } catch (e: ApiException) { 33 | Grove.w { "Google sign in failed with error ${e.message}" } 34 | onGoogleSignInFailed(e) 35 | } 36 | 37 | } 38 | } 39 | 40 | fun onGoogleCredentialReceived(credential: AuthCredential, account: GoogleSignInAccount) 41 | 42 | fun onGoogleSignInFailed(e: ApiException) 43 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/GoogleSignInApiUtils.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import com.google.android.gms.auth.api.signin.GoogleSignInAccount 4 | import frangsierra.kotlinfirechat.session.model.User 5 | 6 | object GoogleSignInApiUtils { 7 | 8 | private const val GOOGLE_ACCOUNT_DEFAULT_IMAGE_SIZE = "s96-c" 9 | private const val GOOGLE_ACCOUNT_DESIRED_IMAGE_SIZE = "s600-c" 10 | 11 | fun getUserData(account: GoogleSignInAccount): User { 12 | return User(email = account.email!!, 13 | photoUrl = retrieveResizedGoogleAccountPicture(account), 14 | username = "${account.displayName}", 15 | uid = "") 16 | } 17 | 18 | private fun retrieveResizedGoogleAccountPicture(account: GoogleSignInAccount): String? = 19 | account.photoUrl?.toString()?.replace(GOOGLE_ACCOUNT_DEFAULT_IMAGE_SIZE, GOOGLE_ACCOUNT_DESIRED_IMAGE_SIZE, true) 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/ImageUtils.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import android.graphics.Bitmap.CompressFormat 4 | import android.graphics.BitmapFactory 5 | import android.net.Uri 6 | import frangsierra.kotlinfirechat.core.flux.app 7 | import io.reactivex.Single 8 | import java.io.ByteArrayOutputStream 9 | import java.io.FileNotFoundException 10 | 11 | object ImageUtils { 12 | private val MAX_THUMB_SIZE = 150 13 | private val POST_MAX_IMAGE_SIZE = 1080 14 | 15 | fun resizeUriToThumbnail(uri: Uri, isUrl: Boolean = false): Single { 16 | return Single.create { emitter -> 17 | try { 18 | val o = BitmapFactory.Options() 19 | o.inJustDecodeBounds = true 20 | 21 | val stream = if (isUrl) java.net.URL(uri.toString()).openStream() else app.contentResolver.openInputStream(uri) 22 | BitmapFactory.decodeStream(stream, null, o) 23 | 24 | var width_tmp = o.outWidth 25 | var height_tmp = o.outHeight 26 | var scale = 1 27 | 28 | while (true) { 29 | if (width_tmp / 2 < MAX_THUMB_SIZE || height_tmp / 2 < MAX_THUMB_SIZE) 30 | break 31 | width_tmp /= 2 32 | height_tmp /= 2 33 | scale *= 2 34 | } 35 | 36 | val o2 = BitmapFactory.Options() 37 | o2.inSampleSize = scale 38 | val resizedStream = if (isUrl) java.net.URL(uri.toString()).openStream() else app.contentResolver.openInputStream(uri) 39 | val decodedResizedBitmap = BitmapFactory.decodeStream(resizedStream, null, o2) 40 | val bos = ByteArrayOutputStream() 41 | decodedResizedBitmap.compress(CompressFormat.PNG, 0 /*ignored for PNG*/, bos) 42 | emitter.onSuccess(bos.toByteArray()) 43 | } catch (e: FileNotFoundException) { 44 | emitter.onError(e) 45 | } 46 | } 47 | } 48 | 49 | fun resizeUriToPostImage(uri: Uri, isUrl: Boolean = false): Single { 50 | return Single.create { emitter -> 51 | try { 52 | val o = BitmapFactory.Options() 53 | o.inJustDecodeBounds = true 54 | 55 | val stream = if (isUrl) java.net.URL(uri.toString()).openStream() else app.contentResolver.openInputStream(uri) 56 | BitmapFactory.decodeStream(stream, null, o) 57 | 58 | var width_tmp = o.outWidth 59 | var height_tmp = o.outHeight 60 | var scale = 1 61 | 62 | while (true) { 63 | if (width_tmp < POST_MAX_IMAGE_SIZE || height_tmp < POST_MAX_IMAGE_SIZE) 64 | break 65 | width_tmp /= 2 66 | height_tmp /= 2 67 | scale *= 2 68 | } 69 | 70 | val o2 = BitmapFactory.Options() 71 | o2.inSampleSize = scale 72 | val resizedStream = if (isUrl) java.net.URL(uri.toString()).openStream() else app.contentResolver.openInputStream(uri) 73 | val decodedResizedBitmap = BitmapFactory.decodeStream(resizedStream, null, o2) 74 | val bos = ByteArrayOutputStream() 75 | decodedResizedBitmap.compress(CompressFormat.PNG, 0 /*ignored for PNG*/, bos) 76 | emitter.onSuccess(bos.toByteArray()) 77 | } catch (e: FileNotFoundException) { 78 | emitter.onError(e) 79 | } 80 | } 81 | } 82 | 83 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/Prefs.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | 7 | class Prefs(context: Context) { 8 | val PREFS_FILENAME = "kotlinfirechat.prefs" 9 | private val LOGGED_USER = "logged_user" 10 | private val LOGGED_USERNAME = "logged_username" 11 | val prefs: SharedPreferences = context.getSharedPreferences(PREFS_FILENAME, 0) 12 | 13 | var loggedUserId: String? 14 | get() { 15 | return prefs.getString(LOGGED_USER, null) 16 | } 17 | @SuppressLint("ApplySharedPref") 18 | set(value) { 19 | prefs.edit().putString(LOGGED_USER, value).commit() 20 | } 21 | 22 | var loggedUsername: String? 23 | get() { 24 | return prefs.getString(LOGGED_USERNAME, null) 25 | } 26 | @SuppressLint("ApplySharedPref") 27 | set(value) { 28 | prefs.edit().putString(LOGGED_USERNAME, value).commit() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/ProgressDialogFragment.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import android.support.v4.app.DialogFragment 4 | import android.support.v4.app.Fragment 5 | import android.support.v7.app.AlertDialog 6 | import android.support.v7.app.AppCompatActivity 7 | import android.view.LayoutInflater 8 | import frangsierra.kotlinfirechat.R 9 | import kotlinx.android.synthetic.main.progress_dialog_layout.view.* 10 | 11 | 12 | /** [DialogFragment] displayed in the app for default purposes. */ 13 | private var customProgressDialog: AlertDialog? = null 14 | 15 | fun AppCompatActivity.showProgressDialog(message: String) { 16 | val progressDialog = LayoutInflater.from(this) 17 | .inflate(R.layout.progress_dialog_layout, null) 18 | progressDialog.dialog_title.text = message 19 | customProgressDialog = AlertDialog.Builder(this) 20 | .createCustomAlertDialog(view = progressDialog, cancelable = false) 21 | 22 | customProgressDialog?.show() 23 | } 24 | 25 | fun AppCompatActivity.dismissProgressDialog() { 26 | customProgressDialog?.dismiss() 27 | customProgressDialog = null 28 | } 29 | 30 | fun Fragment.showProgressDialog(message: String) { 31 | (activity as? AppCompatActivity)?.showProgressDialog(message) 32 | } 33 | 34 | fun Fragment.dismissProgressDialog() { 35 | (activity as? AppCompatActivity)?.dismissProgressDialog() 36 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/RequestState.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import mini.Grove 4 | 5 | data class TypedTask( 6 | val status: Status = TypedTask.Status.IDLE, 7 | val lastUpdate: Long = System.currentTimeMillis(), 8 | val data: T? = null, 9 | val error: Throwable? = null, 10 | val progress: Float? = null) { 11 | 12 | enum class Status { 13 | IDLE, RUNNING, SUCCESS, FAILURE 14 | } 15 | 16 | fun isRunning() = this.status == Status.RUNNING 17 | fun isTerminal() = this.status == Status.SUCCESS || this.status == Status.FAILURE 18 | fun isSuccessful() = this.status == Status.SUCCESS 19 | fun isFailure() = this.status == Status.FAILURE 20 | } 21 | 22 | fun taskRunning(): TypedTask { 23 | return TypedTask(status = TypedTask.Status.RUNNING) 24 | } 25 | 26 | fun taskSuccess(data: T? = null): TypedTask { 27 | return TypedTask(status = TypedTask.Status.SUCCESS, data = data) 28 | } 29 | 30 | fun taskFailure(error: Throwable? = null): TypedTask { 31 | Grove.e(error) { "Task error" } 32 | return TypedTask(status = TypedTask.Status.FAILURE, error = error) 33 | } 34 | 35 | fun taskFailure(error: Throwable? = null, data: T): TypedTask { 36 | Grove.e(error) { "Task error" } 37 | return TypedTask(status = TypedTask.Status.FAILURE, error = error, data = data) 38 | } 39 | 40 | typealias Task = TypedTask -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/RxUtils.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import io.reactivex.* 4 | import io.reactivex.disposables.CompositeDisposable 5 | import io.reactivex.disposables.Disposable 6 | import java.util.concurrent.TimeUnit 7 | 8 | /** Apply the mapping function if object is not null. */ 9 | inline fun Flowable.view(crossinline fn: (T) -> U?): Flowable { 10 | return flatMap { 11 | val mapped = fn(it) 12 | if (mapped == null) Flowable.empty() 13 | else Flowable.just(mapped) 14 | }.distinctUntilChanged() 15 | } 16 | 17 | /** Apply the mapping function if object is not null. */ 18 | inline fun Observable.view(crossinline fn: (T) -> U?): Observable { 19 | return flatMap { 20 | val mapped = fn(it) 21 | if (mapped == null) Observable.empty() 22 | else Observable.just(mapped) 23 | }.distinctUntilChanged() 24 | } 25 | 26 | /** Take the first element that matches the filter function. */ 27 | inline fun Observable.filterOne(crossinline fn: (T) -> Boolean): Maybe { 28 | return filter { fn(it) }.take(1).singleElement() 29 | } 30 | 31 | /** Take the first element that matches the filter function. */ 32 | inline fun Flowable.filterOne(crossinline fn: (T) -> Boolean): Maybe { 33 | return filter { fn(it) }.take(1).singleElement() 34 | } 35 | 36 | inline fun > Flowable.onNextTerminalState( 37 | crossinline mapFn: (S) -> T, 38 | crossinline success: (S) -> Unit = {}, 39 | crossinline failure: (Throwable) -> Unit) { 40 | 41 | filterOne { mapFn(it).isTerminal() } 42 | .subscribe { 43 | val task = mapFn(it) 44 | if (task.isSuccessful()) { 45 | success(it) 46 | } else { 47 | failure(task.error!!) 48 | } 49 | } 50 | } 51 | 52 | fun Completable.defaultTimeout() = timeout(20, TimeUnit.SECONDS) 53 | fun Maybe.defaultTimeout() = timeout(20, TimeUnit.SECONDS) 54 | fun Single.defaultTimeout() = timeout(20, TimeUnit.SECONDS) 55 | fun Observable.defaultTimeout() = timeout(20, TimeUnit.SECONDS) 56 | 57 | interface SubscriptionTracker { 58 | /** Clear Subscriptions. */ 59 | fun cancelSubscriptions() 60 | 61 | /** Start tracking a disposable. */ 62 | fun T.track(): T 63 | } 64 | 65 | class DefaultSubscriptionTracker : SubscriptionTracker { 66 | private val disposables = CompositeDisposable() 67 | override fun cancelSubscriptions() = disposables.clear() 68 | override fun T.track(): T { 69 | disposables.add(this) 70 | return this 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/java/frangsierra/kotlinfirechat/util/UtilExtensions.kt: -------------------------------------------------------------------------------- 1 | package frangsierra.kotlinfirechat.util 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Environment 8 | import android.provider.MediaStore 9 | import android.support.design.widget.TextInputLayout 10 | import android.support.v4.content.FileProvider 11 | import android.support.v7.app.AlertDialog 12 | import android.support.v7.widget.LinearLayoutManager 13 | import android.support.v7.widget.RecyclerView 14 | import android.widget.Toast 15 | import com.google.firebase.auth.FirebaseAuthInvalidCredentialsException 16 | import com.google.firebase.auth.FirebaseAuthInvalidUserException 17 | import com.google.firebase.auth.FirebaseAuthUserCollisionException 18 | import com.google.firebase.auth.FirebaseAuthWeakPasswordException 19 | import frangsierra.kotlinfirechat.R 20 | import java.io.File 21 | import java.util.* 22 | import java.util.concurrent.TimeUnit 23 | 24 | const val TC_REQUEST_GALLERY: Int = 101 25 | const val TC_REQUEST_CAMERA: Int = 102 26 | const val INTENT_TARGET_TYPE: String = "image/*" 27 | const val APP_IMAGE_FOLDER_PATH: String = "firechat/" 28 | val JPG = ".jpg" 29 | 30 | fun Context.toast(message: String, duration: Int = Toast.LENGTH_SHORT) { 31 | Toast.makeText(this, message, duration).show() 32 | } 33 | 34 | fun TextInputLayout.onError(errorText: String? = null, enable: Boolean = true) { 35 | isErrorEnabled = enable 36 | error = errorText 37 | } 38 | 39 | fun RecyclerView.setLinearLayoutManager(context: Context, reverseLayout: Boolean = true, stackFromEnd: Boolean = true) { 40 | val linearLayoutManager = LinearLayoutManager(context) 41 | linearLayoutManager.reverseLayout = reverseLayout 42 | linearLayoutManager.stackFromEnd = stackFromEnd 43 | layoutManager = linearLayoutManager 44 | } 45 | 46 | fun Activity.showImageIntentDialog(outputFileUri: Uri) { 47 | val builder: AlertDialog.Builder = AlertDialog.Builder(this) 48 | builder.setTitle(getString(R.string.choose_image_picker_text)) 49 | builder.setItems(arrayOf(getString(R.string.gallery_text), getString(R.string.camera_text))) { _, which -> 50 | when (which) { 51 | 0 -> { 52 | // GET IMAGE FROM THE GALLERY 53 | val chooser = Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply { 54 | type = INTENT_TARGET_TYPE 55 | }, getString(R.string.choose_image_picture_text)) 56 | startActivityForResult(chooser, TC_REQUEST_GALLERY) 57 | } 58 | 1 -> { 59 | val cameraFolder: File = if (Environment.getExternalStorageState() == android.os.Environment.MEDIA_MOUNTED) 60 | File(Environment.getExternalStorageDirectory(), APP_IMAGE_FOLDER_PATH) 61 | else cacheDir 62 | 63 | if (!cameraFolder.exists()) 64 | cameraFolder.mkdirs() 65 | 66 | val getCameraImage = Intent(MediaStore.ACTION_IMAGE_CAPTURE) 67 | getCameraImage.putExtra(MediaStore.EXTRA_OUTPUT, outputFileUri) 68 | 69 | startActivityForResult(getCameraImage, TC_REQUEST_CAMERA) 70 | } 71 | } 72 | } 73 | builder.show() 74 | } 75 | 76 | fun Long.getTimeAgoText(): String { 77 | val differenceTimeStamp = System.currentTimeMillis() - this 78 | val days = TimeUnit.MILLISECONDS.toDays(differenceTimeStamp) 79 | val hours = TimeUnit.MILLISECONDS.toHours(differenceTimeStamp) 80 | val minutes = TimeUnit.MILLISECONDS.toMinutes(differenceTimeStamp) 81 | val formatString = "%d %s" 82 | return when { 83 | days.div(375) > 0 -> formatString.format(Locale.getDefault(), days.div(375), "y") 84 | days.div(30) > 0 -> formatString.format(Locale.getDefault(), days.div(30), "mon") 85 | days.div(7) > 0 -> formatString.format(Locale.getDefault(), days.div(7), "wk") 86 | days > 0 -> formatString.format(days, "d") 87 | hours > 0 -> formatString.format(hours, "h") 88 | minutes > 0 -> formatString.format(minutes, "min") 89 | else -> formatString.format(Locale.getDefault(), TimeUnit.MILLISECONDS.toSeconds(differenceTimeStamp).plus(1), "s") 90 | } 91 | } 92 | 93 | fun Context.generateUniqueFireUri(): Uri { 94 | // Determine Uri of camera image to save. 95 | //todo change name to new app 96 | val root = File(Environment.getExternalStorageDirectory().toString() + File.separator + "KotlinFirechat" + File.separator) 97 | root.mkdirs() 98 | val fileName = "${System.currentTimeMillis()}$JPG" 99 | val sdImageMainDirectory = File(root, fileName) 100 | return FileProvider.getUriForFile(this, "$packageName.provider", sdImageMainDirectory) 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/firebase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrangSierra/KotlinFirechat/52ea49bb5c07c6689ad5d163e0eba7caad468674/app/src/main/res/drawable/firebase.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/firebase_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrangSierra/KotlinFirechat/52ea49bb5c07c6689ad5d163e0eba7caad468674/app/src/main/res/drawable/firebase_icon.png -------------------------------------------------------------------------------- /app/src/main/res/layout/create_account_activity.xml: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | 19 | 30 | 31 | 32 | 38 | 39 | 50 | 51 | 52 | 58 | 59 | 69 | 70 | 71 |