├── .gitignore ├── .idea ├── compiler.xml ├── copyright │ └── profiles_settings.xml ├── encodings.xml ├── gradle.xml ├── markdown-navigator.xml ├── markdown-navigator │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── runConfigurations.xml └── vcs.xml ├── DataFlow.png ├── README.md ├── build.gradle ├── core ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── karntrehan │ │ └── posts │ │ └── core │ │ ├── application │ │ ├── BaseActivity.kt │ │ └── CoreApp.kt │ │ ├── constants │ │ └── Constants.kt │ │ ├── di │ │ ├── AppModule.kt │ │ ├── CoreComponent.kt │ │ ├── ImageModule.kt │ │ ├── NetworkModule.kt │ │ └── StorageModule.kt │ │ ├── extensions │ │ ├── NetworkingExtensions.kt │ │ ├── OutcomePublishMapper.kt │ │ └── ReactiveExtensions.kt │ │ ├── networking │ │ ├── AppScheduler.kt │ │ ├── Outcome.kt │ │ ├── Scheduler.kt │ │ └── synk │ │ │ ├── Synk.kt │ │ │ └── SynkKeys.kt │ │ └── testing │ │ ├── DependencyProvider.kt │ │ └── TestScheduler.kt │ └── res │ ├── values-v21 │ └── styles.xml │ └── values │ ├── colors.xml │ ├── dimens.xml │ └── styles.xml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── posts.gif ├── posts ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── karntrehan │ │ └── posts │ │ ├── details │ │ └── model │ │ │ └── DetailsLocalDataTest.kt │ │ └── list │ │ └── model │ │ └── ListLocalDataTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── karntrehan │ │ │ └── posts │ │ │ ├── commons │ │ │ ├── PostDH.kt │ │ │ ├── data │ │ │ │ ├── PostWithData.kt │ │ │ │ ├── local │ │ │ │ │ ├── Comment.kt │ │ │ │ │ ├── CommentDao.kt │ │ │ │ │ ├── Post.kt │ │ │ │ │ ├── PostDao.kt │ │ │ │ │ ├── PostDb.kt │ │ │ │ │ ├── User.kt │ │ │ │ │ └── UserDao.kt │ │ │ │ └── remote │ │ │ │ │ └── PostService.kt │ │ │ └── testing │ │ │ │ └── DummyData.kt │ │ │ ├── details │ │ │ ├── DetailsActivity.kt │ │ │ ├── DetailsAdapter.kt │ │ │ ├── di │ │ │ │ ├── DetailsComponent.kt │ │ │ │ └── DetailsScope.kt │ │ │ ├── exceptions │ │ │ │ └── DetailsExceptions.kt │ │ │ ├── model │ │ │ │ ├── DetailsDataContract.kt │ │ │ │ ├── DetailsLocalData.kt │ │ │ │ ├── DetailsRemoteData.kt │ │ │ │ └── DetailsRepository.kt │ │ │ └── viewmodel │ │ │ │ ├── DetailsViewModel.kt │ │ │ │ └── DetailsViewModelFactory.kt │ │ │ └── list │ │ │ ├── ListActivity.kt │ │ │ ├── PostListAdapter.kt │ │ │ ├── di │ │ │ ├── ListComponent.kt │ │ │ └── ListScope.kt │ │ │ ├── model │ │ │ ├── ListDataContract.kt │ │ │ ├── ListLocalData.kt │ │ │ ├── ListRemoteData.kt │ │ │ └── ListRepository.kt │ │ │ └── viewmodel │ │ │ ├── ListViewModel.kt │ │ │ └── ListViewModelFactory.kt │ ├── res │ │ ├── drawable-v24 │ │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ │ ├── activity_details.xml │ │ │ ├── activity_list.xml │ │ │ ├── comment_item.xml │ │ │ └── post_item.xml │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── ic_launcher_round.png │ │ └── values │ │ │ ├── dimens.xml │ │ │ └── strings.xml │ └── resources │ │ └── api-response │ │ ├── comments.json │ │ ├── posts.json │ │ └── users.json │ └── test │ └── java │ └── com │ └── karntrehan │ └── posts │ ├── commons │ └── data │ │ └── remote │ │ └── PostServiceTest.kt │ ├── details │ ├── model │ │ ├── DetailsRemoteDataTest.kt │ │ └── DetailsRepositoryTest.kt │ └── viewmodel │ │ └── DetailsViewModelTest.kt │ └── list │ ├── model │ ├── ListRemoteDataTest.kt │ └── ListRepositoryTest.kt │ └── viewmodel │ └── ListViewModelTest.kt └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | .idea -------------------------------------------------------------------------------- /.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/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/markdown-navigator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 35 | 36 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /.idea/markdown-navigator/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 21 | 31 | 32 | 33 | 34 | 35 | 36 | 38 | 39 | 40 | 41 | 42 | 1.8 43 | 44 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /DataFlow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/DataFlow.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Posts 2 | A sample app to demonstrate the building of a good, modular and scalable Android app using Kotlin, Android Architecture Components (LiveData, ViewModel & Room), Dagger, RxJava and RxAndroid among others. 3 | 4 | # Features 5 | Some of the features of the app include 6 | 7 | - **Effective Networking** - Using a combination of Retrofit, Rx, Room and LiveData, we are able to handle networking in the most effective way. 8 | 9 | - **Modular** - The app is broken into modules of features and libraries which can be combined to build instant-apps, complete apps or lite version of apps. 10 | 11 | - **MVVM architecture** - Using the lifecycle aware viewmodels, the view observes changes in the model / repository. 12 | 13 | - **Kotlin** - This app is completely written in Kotlin. 14 | 15 | - **Android Architecture Components** - Lifecycle awareness has been achieved using a combination of LiveData, ViewModels and Room. 16 | 17 | - **Offline first architecture** - All the data is first tried to be loaded from the db and then updated from the server. This ensures that the app is usable even in an offline mode. 18 | 19 | - **Intelligent sync** -Intelligent hybrid syncing logic makes sure your Android app does not make repeated calls to the same back-end API for the same data in a particular time period. 20 | 21 | - **Dependency Injection** - Common elements like `context`, `networking` interface are injected using Dagger 2. 22 | 23 | - **Feature based packaging** - This screen-wise / feature-wise packaging makes code really easy to read and debug. 24 | 25 | # Working 26 | ![Working](posts.gif) 27 | 28 | # Networking 29 | ![Data flow Diagram](DataFlow.png) 30 | 31 | ### [Activity](posts/src/main/java/com/karntrehan/posts/list/ListActivity.kt) 32 | ```java 33 | viewModel.getPosts() 34 | ``` 35 | 36 | ### [ViewModel](posts/src/main/java/com/karntrehan/posts/list/viewmodel/ListViewModel.kt) 37 | ```java 38 | fun getPosts() { 39 | if (postsOutcome.value == null) 40 | repo.fetchPosts() 41 | } 42 | ``` 43 | 44 | ### [Repository](posts/src/main/java/com/karntrehan/posts/list/model/ListRepository.kt) 45 | ```java 46 | val postFetchOutcome: PublishSubject>> = PublishSubject.create>>() 47 | 48 | override fun fetchPosts() { 49 | postFetchOutcome.loading(true) 50 | //Observe changes to the db 51 | local.getPostsWithUsers() 52 | .performOnBackOutOnMain(scheduler) 53 | .doAfterNext { 54 | if (Synk.shouldSync(SynkKeys.POSTS_HOME, 2, TimeUnit.HOURS)) 55 | refreshPosts() 56 | } 57 | .subscribe({ retailers -> 58 | postFetchOutcome.success(retailers) 59 | }, { error -> handleError(error) }) 60 | .addTo(compositeDisposable) 61 | } 62 | 63 | override fun refreshPosts() { 64 | postFetchOutcome.loading(true) 65 | Flowable.zip( 66 | remote.getUsers(), 67 | remote.getPosts(), 68 | zipUsersAndPosts() 69 | ) 70 | .performOnBackOutOnMain(scheduler) 71 | .updateSynkStatus(key = SynkKeys.POSTS_HOME) 72 | .subscribe({}, { error -> handleError(error) }) 73 | .addTo(compositeDisposable) 74 | } 75 | 76 | private fun zipUsersAndPosts() = 77 | BiFunction, List, Unit> { users, posts -> 78 | saveUsersAndPosts(users, posts) 79 | } 80 | 81 | override fun saveUsersAndPosts(users: List, posts: List) { 82 | local.saveUsersAndPosts(users, posts) 83 | } 84 | 85 | override fun handleError(error: Throwable) { 86 | postFetchOutcome.failed(error) 87 | } 88 | ``` 89 | 90 | ### [ViewModel](posts/src/main/java/com/karntrehan/posts/list/viewmodel/ListViewModel.kt) ### 91 | ```java 92 | val postsOutcome: LiveData>> by lazy { 93 | //Convert publish subject to livedata 94 | repo.postFetchOutcome.toLiveData(compositeDisposable) 95 | } 96 | ``` 97 | 98 | ### [Activity](posts/src/main/java/com/karntrehan/posts/list/ListActivity.kt) 99 | ```java 100 | viewModel.postsOutcome.observe(this, Observer>> { outcome -> 101 | when (outcome) { 102 | 103 | is Outcome.Progress -> srlPosts.isRefreshing = outcome.loading 104 | 105 | is Outcome.Success -> { 106 | Log.d(TAG, "initiateDataListener: Successfully loaded data") 107 | adapter.setData(outcome.data) 108 | } 109 | 110 | is Outcome.Failure -> { 111 | if (outcome.e is IOException) 112 | Toast.makeText(context, R.string.need_internet_posts, Toast.LENGTH_LONG).show() 113 | else 114 | Toast.makeText(context, R.string.failed_post_try_again, Toast.LENGTH_LONG).show() 115 | } 116 | 117 | } 118 | }) 119 | ``` 120 | 121 | 122 | # Testing: 123 | To run all the unit tests, run `./gradlew test`. This would test the repositories and the viewmodels. 124 | 125 | To run all the instrumented tests, run `./gradlew connectedAndroidTest`. This would test the LocalDataSources (Room) 126 | 127 | # Build info: 128 | - Android Studio - 3.1 Canary 8 129 | - Compile SDK - 28 130 | - MinSDK - 16, Target - 28 131 | 132 | # Articles 133 | To read more about the architecture choices and the decisions behind this project, kindly refer to the following articles: 134 | * [Effective Networking On Android using Retrofit, Rx and Architecture Components](https://medium.com/mindorks/effective-networking-on-android-using-retrofit-rx-and-architecture-components-4554ca5b167d) 135 | * [Writing a modular project on Android](https://medium.com/mindorks/writing-a-modular-project-on-android-304f3b09cb37) 136 | * [To Synk or not to Synk](https://medium.com/mindorks/to-synk-or-not-to-synk-fcc6e4c56e14) 137 | 138 | **Talk to the developer about this project**: [@karntrehan](https://twitter.com/karntrehan) 139 | 140 | # Other samples 141 | Below are some of the other samples I have opensourced: 142 | * [Starwars](https://github.com/karntrehan/Starwars) : 2019 - A sample modular Android app written in Kotlin using Rx, Koin, Coroutines, Dagger 2 and Architecture components 143 | * [Agni](https://github.com/karntrehan/Agni) : 2019 - Android app template for modular apps with Dagger 2, Coroutines, LiveData, ViewModel and RxJava 2. 144 | * [Talko](https://github.com/karntrehan/Talko) : 2019 - A sample messaging UI app for Android writen in Kotlin with a working local persistence layer. 145 | 146 | # Libraries used 147 | * [Android Support Libraries](https://developer.android.com/topic/libraries/support-library/index.html) 148 | * [Dagger 2](https://google.github.io/dagger/) 149 | * [Retrofit](http://square.github.io/retrofit/) 150 | * [OkHttp](http://square.github.io/okhttp/) 151 | * [Picasso](http://square.github.io/picasso/) 152 | * [Stetho](http://facebook.github.io/stetho/) 153 | * [Room](https://developer.android.com/topic/libraries/architecture/room.html) 154 | * [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel.html) 155 | * [LiveData](https://developer.android.com/topic/libraries/architecture/livedata.html) 156 | * [RxJava](https://github.com/ReactiveX/RxJava) 157 | * [RxAndroid](https://github.com/ReactiveX/RxAndroid) 158 | 159 | # License 160 | 161 | Copyright 2018 Karan Trehan 162 | 163 | Licensed under the Apache License, Version 2.0 (the "License"); 164 | you may not use this file except in compliance with the License. 165 | You may obtain a copy of the License at 166 | 167 | http://www.apache.org/licenses/LICENSE-2.0 168 | 169 | Unless required by applicable law or agreed to in writing, software 170 | distributed under the License is distributed on an "AS IS" BASIS, 171 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 172 | See the License for the specific language governing permissions and 173 | limitations under the License. -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | ext.versions = [ 5 | 'minSdk' : 16, 6 | 'compileSdk' : 28, 7 | 8 | 'buildTools' : '28.0.3', 9 | 'androidPlugin' : '3.2.1', 10 | 'kotlin' : '1.3.11', 11 | 'gms' : '3.1.0', 12 | 'dagger' : '2.12', 13 | 'gson' : '2.8.0', 14 | 'retrofit' : '2.3.0', 15 | 'retrofitRxAdapter': '1.0.0', 16 | 'okhttp' : '3.9.0', 17 | 'okhttpDownloader' : '1.1.0', 18 | 'picasso' : '2.5.2', 19 | 'rxJava' : '2.1.7', 20 | 'rxAndroid' : '2.0.1', 21 | 'jodaTime' : "2.9.9", 22 | 'stetho' : '1.5.0', 23 | 'junit' : '4.12', 24 | 'atsl' : '1.0.1', 25 | 'mockitoKotlin' : '1.5.0', 26 | 'robolectric' : '4.3', 27 | 'sourceCompat' : JavaVersion.VERSION_1_8, 28 | 'targetCompat' : JavaVersion.VERSION_1_8 29 | ] 30 | 31 | 32 | ext.deps = [ 33 | android : [ 34 | 'gradlePlugin' : "com.android.tools.build:gradle:${versions.androidPlugin}", 35 | 'lifecycleExt' : 'androidx.lifecycle:lifecycle-extensions:2.0.0', 36 | 'lifecycleCommon': 'androidx.lifecycle:lifecycle-common-java8:2.0.0', 37 | 'roomRuntime' : 'androidx.room:room-runtime:2.0.0', 38 | 'roomCompiler' : 'androidx.room:room-compiler:2.0.0', 39 | 'roomRx' : 'androidx.room:room-rxjava2:2.0.0', 40 | ], 41 | support : [ 42 | 'appCompat' : 'androidx.appcompat:appcompat:1.0.0', 43 | 'recyclerView' : 'androidx.recyclerview:recyclerview:1.0.0', 44 | 'cardView' : 'androidx.cardview:cardview:1.0.0', 45 | 'support' : 'androidx.legacy:legacy-support-v4:1.0.0', 46 | 'designSupport': 'com.google.android.material:material:1.0.0', 47 | ], 48 | kotlin : [ 49 | 'gradlePlugin': "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}", 50 | 'stdlib7' : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}", 51 | ], 52 | reactivex : [ 53 | 'rxJava' : "io.reactivex.rxjava2:rxjava:${versions.rxJava}", 54 | 'rxAndroid': "io.reactivex.rxjava2:rxandroid:${versions.rxAndroid}" 55 | ], 56 | google : [ 57 | 'gmsPlugin' : "com.google.gms:google-services:${versions.gms}", 58 | 'dagger' : "com.google.dagger:dagger:${versions.dagger}", 59 | 'daggerProcessor': "com.google.dagger:dagger-compiler:${versions.dagger}", 60 | 'gson' : "com.google.code.gson:gson:${versions.gson}", 61 | ], 62 | square : [ 63 | 'retrofit' : "com.squareup.retrofit2:retrofit:${versions.retrofit}", 64 | 'gsonConverter' : "com.squareup.retrofit2:converter-gson:${versions.retrofit}", 65 | 'okhttp' : "com.squareup.okhttp3:okhttp:${versions.okhttp}", 66 | 'picasso' : "com.squareup.picasso:picasso:${versions.picasso}", 67 | 'okhttpDownloader' : "com.jakewharton.picasso:picasso2-okhttp3-downloader:${versions.okhttpDownloader}", 68 | 'retrofitRxAdapter': "com.jakewharton.retrofit:retrofit2-rxjava2-adapter:${versions.retrofitRxAdapter}", 69 | ], 70 | facebook : [ 71 | 'stetho' : "com.facebook.stetho:stetho:${versions.stetho}", 72 | 'networkInterceptor': "com.facebook.stetho:stetho-okhttp3:${versions.stetho}" 73 | ], 74 | additional: [ 75 | 'jodaTime': "joda-time:joda-time:${versions.jodaTime}" 76 | ], 77 | test : [ 78 | 'junit' : "junit:junit:${versions.junit}", 79 | 'atslRunner' : 'androidx.test.ext:junit:1.1.0', 80 | 'atslRules' : 'androidx.test:rules:1.1.0', 81 | 'mockitoKotlin' : "com.nhaarman:mockito-kotlin:${versions.mockitoKotlin}", 82 | 'robolectric' : "org.robolectric:robolectric:${versions.robolectric}", 83 | 'mockWebServer' : "com.squareup.okhttp3:mockwebserver:${versions.okhttp}", 84 | 'roomTesting' : 'androidx.room:room-testing:2.0.0', 85 | 'livedataTesting': 'androidx.arch.core:core-testing:2.0.0', 86 | ] 87 | ] 88 | repositories { 89 | google() 90 | jcenter() 91 | } 92 | dependencies { 93 | classpath deps.android.gradlePlugin 94 | classpath deps.google.gmsPlugin 95 | classpath deps.kotlin.gradlePlugin 96 | 97 | // NOTE: Do not place your application dependencies here; they belong 98 | // in the individual module build.gradle files 99 | } 100 | } 101 | 102 | allprojects { 103 | repositories { 104 | google() 105 | jcenter() 106 | } 107 | } 108 | 109 | task clean(type: Delete) { 110 | delete rootProject.buildDir 111 | } 112 | -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /core/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: 'kotlin-android-extensions' 5 | 6 | android { 7 | compileSdkVersion versions.compileSdk 8 | buildToolsVersion versions.buildTools 9 | 10 | defaultConfig { 11 | minSdkVersion versions.minSdk 12 | targetSdkVersion versions.compileSdk 13 | versionCode 1 14 | versionName "1.0" 15 | 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | 18 | } 19 | 20 | buildTypes { 21 | release { 22 | postprocessing { 23 | removeUnusedCode false 24 | removeUnusedResources false 25 | obfuscate false 26 | optimizeCode false 27 | proguardFile 'proguard-rules.pro' 28 | } 29 | buildConfigField 'String', 'BASE_URL', '"http://jsonplaceholder.typicode.com/"' 30 | } 31 | debug { 32 | buildConfigField 'String', 'BASE_URL', '"http://jsonplaceholder.typicode.com/"' 33 | } 34 | } 35 | 36 | compileOptions { 37 | sourceCompatibility versions.sourceCompat 38 | targetCompatibility versions.targetCompat 39 | } 40 | } 41 | 42 | dependencies { 43 | api fileTree(include: ['*.jar'], dir: 'libs') 44 | api deps.support.appCompat 45 | api deps.support.recyclerView 46 | api deps.support.cardView 47 | api deps.support.support 48 | api deps.support.designSupport 49 | 50 | api deps.android.lifecycleExt 51 | api deps.android.lifecycleCommon 52 | api deps.android.roomRuntime 53 | api deps.android.roomRx 54 | 55 | api deps.kotlin.stdlib7 56 | 57 | api deps.reactivex.rxJava 58 | api deps.reactivex.rxAndroid 59 | 60 | api deps.google.dagger 61 | kapt deps.google.daggerProcessor 62 | api deps.google.gson 63 | 64 | api deps.square.picasso 65 | api deps.square.okhttpDownloader 66 | 67 | api deps.square.retrofit 68 | api deps.square.okhttp 69 | api deps.square.gsonConverter 70 | api deps.square.retrofitRxAdapter 71 | 72 | api deps.additional.jodaTime 73 | 74 | api deps.facebook.stetho 75 | api deps.facebook.networkInterceptor 76 | } 77 | -------------------------------------------------------------------------------- /core/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/application/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.application 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import android.view.MenuItem 6 | 7 | abstract class BaseActivity:AppCompatActivity(){ 8 | override fun onCreate(savedInstanceState: Bundle?) { 9 | super.onCreate(savedInstanceState) 10 | 11 | setUpToolbar() 12 | } 13 | 14 | private fun setUpToolbar() { 15 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 16 | } 17 | 18 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 19 | val id = item.itemId 20 | if (id == android.R.id.home) { 21 | finish() 22 | } 23 | return super.onOptionsItemSelected(item) 24 | } 25 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/application/CoreApp.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.application 2 | 3 | import android.app.Application 4 | import com.facebook.stetho.Stetho 5 | import com.karntrehan.posts.core.BuildConfig 6 | import com.karntrehan.posts.core.di.AppModule 7 | import com.karntrehan.posts.core.di.CoreComponent 8 | import com.karntrehan.posts.core.di.DaggerCoreComponent 9 | import com.karntrehan.posts.core.networking.synk.Synk 10 | 11 | open class CoreApp : Application() { 12 | 13 | companion object { 14 | lateinit var coreComponent: CoreComponent 15 | } 16 | 17 | override fun onCreate() { 18 | super.onCreate() 19 | initSynk() 20 | initDI() 21 | initStetho() 22 | } 23 | 24 | private fun initSynk() { 25 | Synk.init(this) 26 | } 27 | 28 | private fun initStetho() { 29 | if (BuildConfig.DEBUG) 30 | Stetho.initializeWithDefaults(this) 31 | } 32 | 33 | private fun initDI() { 34 | coreComponent = DaggerCoreComponent.builder().appModule(AppModule(this)).build() 35 | } 36 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/constants/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.constants 2 | 3 | import com.karntrehan.posts.core.BuildConfig 4 | 5 | object Constants { 6 | val API_URL = BuildConfig.BASE_URL 7 | 8 | object Posts { 9 | val DB_NAME = "posts_db" 10 | } 11 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.di 2 | 3 | import android.content.Context 4 | import com.karntrehan.posts.core.networking.AppScheduler 5 | import com.karntrehan.posts.core.networking.Scheduler 6 | import dagger.Module 7 | import dagger.Provides 8 | import javax.inject.Singleton 9 | 10 | @Module 11 | class AppModule(val context: Context) { 12 | @Provides 13 | @Singleton 14 | fun providesContext(): Context { 15 | return context 16 | } 17 | 18 | @Provides 19 | @Singleton 20 | fun scheduler(): Scheduler { 21 | return AppScheduler() 22 | } 23 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/di/CoreComponent.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.di 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import com.karntrehan.posts.core.networking.Scheduler 6 | import com.squareup.picasso.Picasso 7 | import dagger.Component 8 | import retrofit2.Retrofit 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | @Component(modules = [AppModule::class, NetworkModule::class, StorageModule::class, ImageModule::class]) 13 | interface CoreComponent { 14 | 15 | fun context(): Context 16 | 17 | fun retrofit(): Retrofit 18 | 19 | fun picasso(): Picasso 20 | 21 | fun sharedPreferences(): SharedPreferences 22 | 23 | fun scheduler(): Scheduler 24 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/di/ImageModule.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.di 2 | 3 | import android.content.Context 4 | import com.jakewharton.picasso.OkHttp3Downloader 5 | import com.squareup.picasso.Picasso 6 | import dagger.Module 7 | import dagger.Provides 8 | import okhttp3.OkHttpClient 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | class ImageModule { 13 | @Provides 14 | @Singleton 15 | fun providesOkhttp3Downloader(okHttpClient: OkHttpClient): OkHttp3Downloader { 16 | return OkHttp3Downloader(okHttpClient) 17 | } 18 | 19 | @Provides 20 | @Singleton 21 | fun providesPicasso(context: Context, okHttp3Downloader: OkHttp3Downloader): Picasso { 22 | return Picasso.Builder(context) 23 | .downloader(okHttp3Downloader) 24 | .build() 25 | } 26 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.di 2 | 3 | import android.content.Context 4 | import com.facebook.stetho.okhttp3.StethoInterceptor 5 | import com.google.gson.Gson 6 | import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 7 | import com.karntrehan.posts.core.BuildConfig 8 | import com.karntrehan.posts.core.constants.Constants 9 | import dagger.Module 10 | import dagger.Provides 11 | import okhttp3.Cache 12 | import okhttp3.OkHttpClient 13 | import retrofit2.Retrofit 14 | import retrofit2.converter.gson.GsonConverterFactory 15 | import java.util.concurrent.TimeUnit 16 | import javax.inject.Singleton 17 | 18 | @Module 19 | class NetworkModule { 20 | 21 | @Provides 22 | @Singleton 23 | fun providesRetrofit( 24 | gsonConverterFactory: GsonConverterFactory, 25 | rxJava2CallAdapterFactory: RxJava2CallAdapterFactory, 26 | okHttpClient: OkHttpClient 27 | ): Retrofit { 28 | return Retrofit.Builder().baseUrl(Constants.API_URL) 29 | .addConverterFactory(gsonConverterFactory) 30 | .addCallAdapterFactory(rxJava2CallAdapterFactory) 31 | .client(okHttpClient) 32 | .build() 33 | } 34 | 35 | @Provides 36 | @Singleton 37 | fun providesOkHttpClient(cache: Cache): OkHttpClient { 38 | val client = OkHttpClient.Builder() 39 | .cache(cache) 40 | .connectTimeout(10, TimeUnit.SECONDS) 41 | .writeTimeout(30, TimeUnit.SECONDS) 42 | .readTimeout(10, TimeUnit.SECONDS) 43 | 44 | if (BuildConfig.DEBUG) 45 | client.addNetworkInterceptor(StethoInterceptor()) 46 | 47 | return client.build() 48 | } 49 | 50 | @Provides 51 | @Singleton 52 | fun providesOkhttpCache(context: Context): Cache { 53 | val cacheSize = 10 * 1024 * 1024 // 10 MB 54 | return Cache(context.cacheDir, cacheSize.toLong()) 55 | } 56 | 57 | @Provides 58 | @Singleton 59 | fun providesGson(): Gson { 60 | return Gson() 61 | } 62 | 63 | @Provides 64 | @Singleton 65 | fun providesGsonConverterFactory(): GsonConverterFactory { 66 | return GsonConverterFactory.create() 67 | } 68 | 69 | @Provides 70 | @Singleton 71 | fun providesRxJavaCallAdapterFactory(): RxJava2CallAdapterFactory { 72 | return RxJava2CallAdapterFactory.create() 73 | } 74 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/di/StorageModule.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.di 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.preference.PreferenceManager 6 | import dagger.Module 7 | import dagger.Provides 8 | import javax.inject.Singleton 9 | 10 | @Module 11 | class StorageModule { 12 | @Provides 13 | @Singleton 14 | fun providesSharedPreferences(context: Context): SharedPreferences { 15 | return PreferenceManager.getDefaultSharedPreferences(context) 16 | } 17 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/extensions/NetworkingExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.extensions 2 | 3 | import com.karntrehan.posts.core.networking.synk.Synk 4 | import io.reactivex.Single 5 | 6 | /** 7 | * Extension function to update [Synk] about a remote call's status 8 | **/ 9 | fun Single.updateSynkStatus(key: String): Single { 10 | return this.doOnSuccess { Synk.syncSuccess(key = key) } 11 | .doOnError { Synk.syncFailure(key = key) } 12 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/extensions/OutcomePublishMapper.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.extensions 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.MutableLiveData 5 | import com.mpaani.core.networking.Outcome 6 | import io.reactivex.disposables.CompositeDisposable 7 | import io.reactivex.subjects.PublishSubject 8 | 9 | /** 10 | * Created by karn on 18/1/18. 11 | **/ 12 | 13 | /** 14 | * Extension function to convert a Publish subject into a LiveData by subscribing to it. 15 | **/ 16 | fun PublishSubject.toLiveData(compositeDisposable: CompositeDisposable): LiveData { 17 | val data = MutableLiveData() 18 | compositeDisposable.add(this.subscribe({ t: T -> data.value = t })) 19 | return data 20 | } 21 | 22 | /** 23 | * Extension function to push a failed event with an exception to the observing outcome 24 | * */ 25 | fun PublishSubject>.failed(e: Throwable) { 26 | with(this){ 27 | loading(false) 28 | onNext(Outcome.failure(e)) 29 | } 30 | } 31 | 32 | /** 33 | * Extension function to push a success event with data to the observing outcome 34 | * */ 35 | fun PublishSubject>.success(t: T) { 36 | with(this){ 37 | loading(false) 38 | onNext(Outcome.success(t)) 39 | } 40 | } 41 | 42 | /** 43 | * Extension function to push the loading status to the observing outcome 44 | * */ 45 | fun PublishSubject>.loading(isLoading: Boolean) { 46 | this.onNext(Outcome.loading(isLoading)) 47 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/extensions/ReactiveExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.extensions 2 | 3 | import com.karntrehan.posts.core.networking.Scheduler 4 | import io.reactivex.Completable 5 | import io.reactivex.Flowable 6 | import io.reactivex.Observable 7 | import io.reactivex.Single 8 | import io.reactivex.disposables.CompositeDisposable 9 | import io.reactivex.disposables.Disposable 10 | 11 | /** 12 | * Extension function to subscribe on the background thread and observe on the main thread for a [Completable] 13 | * */ 14 | fun Completable.performOnBackOutOnMain(scheduler: Scheduler): Completable { 15 | return this.subscribeOn(scheduler.io()) 16 | .observeOn(scheduler.mainThread()) 17 | } 18 | 19 | /** 20 | * Extension function to subscribe on the background thread and observe on the main thread for a [Flowable] 21 | * */ 22 | fun Flowable.performOnBackOutOnMain(scheduler: Scheduler): Flowable { 23 | return this.subscribeOn(scheduler.io()) 24 | .observeOn(scheduler.mainThread()) 25 | } 26 | 27 | /** 28 | * Extension function to subscribe on the background thread and observe on the main thread for a [Single] 29 | * */ 30 | fun Single.performOnBackOutOnMain(scheduler: Scheduler): Single { 31 | return this.subscribeOn(scheduler.io()) 32 | .observeOn(scheduler.mainThread()) 33 | } 34 | 35 | /** 36 | * Extension function to subscribe on the background thread and observe on the main thread for a [Observable] 37 | * */ 38 | fun Observable.performOnBackOutOnMain(scheduler: Scheduler): Observable { 39 | return this.subscribeOn(scheduler.io()) 40 | .observeOn(scheduler.mainThread()) 41 | } 42 | 43 | /** 44 | * Extension function to add a Disposable to a CompositeDisposable 45 | */ 46 | fun Disposable.addTo(compositeDisposable: CompositeDisposable) { 47 | compositeDisposable.add(this) 48 | } 49 | 50 | /** 51 | * Extension function to subscribe on the background thread for a Flowable 52 | * */ 53 | fun Flowable.performOnBack(scheduler: Scheduler): Flowable { 54 | return this.subscribeOn(scheduler.io()) 55 | } 56 | 57 | /** 58 | * Extension function to subscribe on the background thread for a Completable 59 | * */ 60 | fun Completable.performOnBack(scheduler: Scheduler): Completable { 61 | return this.subscribeOn(scheduler.io()) 62 | } 63 | 64 | /** 65 | * Extension function to subscribe on the background thread for a Observable 66 | * */ 67 | fun Observable.performOnBack(scheduler: Scheduler): Observable { 68 | return this.subscribeOn(scheduler.io()) 69 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/networking/AppScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.networking 2 | 3 | import io.reactivex.android.schedulers.AndroidSchedulers 4 | import io.reactivex.schedulers.Schedulers 5 | 6 | /** 7 | * Implementation of [Scheduler] with actual threads. 8 | * */ 9 | class AppScheduler : Scheduler { 10 | 11 | override fun mainThread(): io.reactivex.Scheduler { 12 | return AndroidSchedulers.mainThread() 13 | } 14 | 15 | override fun io(): io.reactivex.Scheduler { 16 | return Schedulers.io() 17 | } 18 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/networking/Outcome.kt: -------------------------------------------------------------------------------- 1 | package com.mpaani.core.networking 2 | 3 | /** 4 | * Created by karn on 18/1/18. 5 | */ 6 | sealed class Outcome { 7 | data class Progress(var loading: Boolean) : Outcome() 8 | data class Success(var data: T) : Outcome() 9 | data class Failure(val e: Throwable) : Outcome() 10 | 11 | companion object { 12 | fun loading(isLoading: Boolean): Outcome = Progress(isLoading) 13 | 14 | fun success(data: T): Outcome = Success(data) 15 | 16 | fun failure(e: Throwable): Outcome = Failure(e) 17 | } 18 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/networking/Scheduler.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.networking 2 | 3 | import io.reactivex.Scheduler 4 | 5 | /** 6 | * Interface to mock different threads during testing. 7 | * */ 8 | interface Scheduler { 9 | fun mainThread():Scheduler 10 | fun io():Scheduler 11 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/networking/synk/Synk.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.networking.synk 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import android.preference.PreferenceManager 6 | import org.joda.time.DateTime 7 | import org.joda.time.format.ISODateTimeFormat 8 | import java.util.concurrent.TimeUnit 9 | 10 | /** 11 | * A Singleton object to identify if a sync operation should run or not. 12 | * */ 13 | object Synk { 14 | 15 | private var preferences: SharedPreferences? = null 16 | const val TAG = "Synk" 17 | private const val SYNK_IT = true 18 | private const val DONT_SYNK = false 19 | 20 | /** 21 | * Initialize the preference object to read and write sync operation date-times. 22 | * Preferably should be initialized in the application class. 23 | **/ 24 | fun init(context: Context) { 25 | preferences = PreferenceManager.getDefaultSharedPreferences(context) 26 | } 27 | 28 | /** 29 | * Check if the sync operation should run or not. 30 | * 31 | * Note: Requires the [init] function to be called beforehand once per application run time. 32 | * 33 | * @param key key of the sync operation 34 | * @param window amount of the delay between successive syncs 35 | * @param unit [TimeUnit] of [window] 36 | * @return [Boolean] to be checked if the sync should run 37 | * 38 | * @throws IllegalStateException if [unit] is [TimeUnit.MILLISECONDS], [TimeUnit.NANOSECONDS], [TimeUnit.MICROSECONDS] or [TimeUnit.SECONDS] 39 | **/ 40 | fun shouldSync(key: String, window: Int = 4, unit: TimeUnit = TimeUnit.HOURS): Boolean { 41 | 42 | performPrefsSanityCheck() 43 | 44 | if (unit == TimeUnit.MILLISECONDS 45 | || unit == TimeUnit.NANOSECONDS 46 | || unit == TimeUnit.MICROSECONDS 47 | || unit == TimeUnit.SECONDS 48 | ) 49 | throw IllegalStateException("Illegal time window") 50 | 51 | val currentSavedValue = preferences?.getString(key, "") 52 | 53 | if (currentSavedValue.isNullOrEmpty()) //Operation has never run or Synk doesn't know about it 54 | return syncIt(key) 55 | 56 | val syncedTime = DateTime.parse(currentSavedValue) 57 | val syncBlock = when (unit) { //Identify the block window from last sync 58 | TimeUnit.MINUTES -> syncedTime.plusMinutes(window) 59 | TimeUnit.HOURS -> syncedTime.plusHours(window) 60 | TimeUnit.DAYS -> syncedTime.plusDays(window) 61 | else -> syncedTime 62 | } 63 | 64 | //Is the current time past the sync block window? 65 | return if (DateTime.now() >= syncBlock) syncIt(key) else DONT_SYNK 66 | } 67 | 68 | /** 69 | * Tell Synk that the sync operation was a success. 70 | * 71 | * This function saves the current time with the passed key into preferences. 72 | * 73 | * @param key String key of the sync operation 74 | **/ 75 | fun syncSuccess(key: String) { 76 | performPrefsSanityCheck() 77 | saveSyncTime(key) 78 | } 79 | 80 | /** 81 | * Tell Synk that the sync operation was a failure. 82 | * 83 | * This function removes the passed key from preferences. 84 | * 85 | * @param key String key of the sync operation 86 | **/ 87 | fun syncFailure(key: String) { 88 | performPrefsSanityCheck() 89 | preferences 90 | ?.edit() 91 | ?.remove(key) 92 | ?.apply() 93 | } 94 | 95 | /** 96 | * Checks if the preferences object has been initialized. 97 | * 98 | * @throws IllegalStateException if prefs not initialized 99 | **/ 100 | private fun performPrefsSanityCheck() { 101 | if (preferences == null) 102 | throw IllegalStateException("Make sure to init Synk") 103 | } 104 | 105 | /** 106 | * Triggers a save into preferences with current time for mentioned key, 107 | * preventing multiple sync calls if first call is still under progress. 108 | * 109 | * @param key key of the sync operation 110 | **/ 111 | private fun syncIt(key: String): Boolean { 112 | saveSyncTime(key) 113 | return SYNK_IT 114 | } 115 | 116 | /** 117 | * Saves the mentioned datetime into the preferences in ISODateTimeFormat string. 118 | * 119 | * @param key key of the sync operation 120 | * @param dateTime [DateTime] to be saved. 121 | * */ 122 | private fun saveSyncTime(key: String, dateTime: DateTime = DateTime.now()) { 123 | preferences 124 | ?.edit() 125 | ?.putString(key, ISODateTimeFormat.dateTime().print(dateTime)) 126 | ?.apply() 127 | } 128 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/networking/synk/SynkKeys.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.networking.synk 2 | 3 | object SynkKeys { 4 | const val POSTS_HOME = "sync_posts_home" 5 | const val POST_DETAILS = "sync_post_details" 6 | 7 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/testing/DependencyProvider.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.testing 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.annotation.VisibleForTesting 5 | import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 6 | import okhttp3.HttpUrl 7 | import okhttp3.OkHttpClient 8 | import okio.Okio 9 | import retrofit2.Retrofit 10 | import retrofit2.converter.gson.GsonConverterFactory 11 | import java.nio.charset.StandardCharsets 12 | import java.util.concurrent.TimeUnit 13 | 14 | /** 15 | * Only for Testing 16 | * Shouldn't be used in actual production code 17 | */ 18 | @VisibleForTesting(otherwise = VisibleForTesting.NONE) 19 | object DependencyProvider { 20 | 21 | /** 22 | * Returns a Retrofit instance for Testing 23 | */ 24 | fun getRetrofit(baseUrl: HttpUrl): Retrofit { 25 | return Retrofit.Builder().baseUrl(baseUrl) 26 | .addConverterFactory(GsonConverterFactory.create()) 27 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 28 | .client(OkHttpClient.Builder() 29 | .connectTimeout(10, TimeUnit.SECONDS) 30 | .writeTimeout(30, TimeUnit.SECONDS) 31 | .readTimeout(10, TimeUnit.SECONDS).build()) 32 | .build() 33 | } 34 | 35 | 36 | /** 37 | *Helper to read a JSON file and return a JSON string 38 | *Note: JSON file should have the structure "module/resources/api-response/filename.json" 39 | * @param fileName: File's name 40 | * @return JSON String 41 | */ 42 | @SuppressLint("NewApi") 43 | fun getResponseFromJson(fileName: String): String { 44 | val inputStream = javaClass.classLoader 45 | .getResourceAsStream("api-response/$fileName.json") 46 | val source = Okio.buffer(Okio.source(inputStream)) 47 | return source.readString(StandardCharsets.UTF_8) 48 | } 49 | } -------------------------------------------------------------------------------- /core/src/main/java/com/karntrehan/posts/core/testing/TestScheduler.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.core.testing 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import com.karntrehan.posts.core.networking.Scheduler 5 | import io.reactivex.schedulers.Schedulers 6 | 7 | @VisibleForTesting(otherwise = VisibleForTesting.NONE) 8 | class TestScheduler : Scheduler { 9 | 10 | override fun mainThread(): io.reactivex.Scheduler { 11 | return Schedulers.trampoline() 12 | } 13 | 14 | override fun io(): io.reactivex.Scheduler { 15 | return Schedulers.trampoline() 16 | } 17 | } -------------------------------------------------------------------------------- /core/src/main/res/values-v21/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /core/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #009688 4 | #00796B 5 | #B2DFDB 6 | #FFC107 7 | #212121 8 | #757575 9 | #FFFFFF 10 | #BDBDBD 11 | #c0392b 12 | 13 | -------------------------------------------------------------------------------- /core/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5dp 4 | 4dp 5 | 4dp 6 | 24dp 7 | -------------------------------------------------------------------------------- /core/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | #org.gradle.jvmargs=-Xmx1536m 13 | 14 | # When configured, Gradle will run in incubating parallel mode. 15 | # This option should only be used with decoupled projects. More details, visit 16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 17 | # org.gradle.parallel=true 18 | android.enableJetifier=true 19 | android.useAndroidX=true 20 | org.gradle.jvmargs=-Xmx1024m -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Dec 19 18:38:04 IST 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /posts.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts.gif -------------------------------------------------------------------------------- /posts/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /posts/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-kapt' 4 | apply plugin: 'kotlin-android-extensions' 5 | 6 | android { 7 | compileSdkVersion versions.compileSdk 8 | buildToolsVersion versions.buildTools 9 | 10 | defaultConfig { 11 | applicationId "com.karntrehan.posts" 12 | minSdkVersion versions.minSdk 13 | targetSdkVersion versions.compileSdk 14 | versionCode 3 15 | versionName "1.2" 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | buildTypes { 19 | release { 20 | postprocessing { 21 | removeUnusedCode false 22 | removeUnusedResources false 23 | obfuscate false 24 | optimizeCode false 25 | proguardFile 'proguard-rules.pro' 26 | } 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility versions.sourceCompat 31 | targetCompatibility versions.targetCompat 32 | } 33 | 34 | //For testing 35 | testOptions { 36 | unitTests { 37 | includeAndroidResources = true 38 | } 39 | } 40 | } 41 | 42 | androidExtensions { 43 | experimental = true 44 | } 45 | 46 | dependencies { 47 | implementation project(':core') 48 | kapt deps.android.roomCompiler 49 | kapt deps.google.daggerProcessor 50 | 51 | //Local unit tests 52 | testImplementation deps.test.junit 53 | testImplementation deps.test.mockitoKotlin 54 | testImplementation deps.test.robolectric 55 | testImplementation deps.test.mockWebServer 56 | testImplementation deps.test.livedataTesting 57 | testImplementation deps.test.roomTesting 58 | 59 | androidTestImplementation deps.test.junit 60 | androidTestImplementation deps.test.mockitoKotlin 61 | androidTestImplementation deps.test.atslRunner 62 | androidTestImplementation deps.test.atslRules 63 | androidTestImplementation deps.test.roomTesting 64 | androidTestImplementation deps.test.livedataTesting 65 | } 66 | -------------------------------------------------------------------------------- /posts/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /posts/src/androidTest/java/com/karntrehan/posts/details/model/DetailsLocalDataTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.model 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.room.Room 5 | import androidx.test.platform.app.InstrumentationRegistry 6 | import androidx.test.ext.junit.runners.AndroidJUnit4 7 | import com.karntrehan.posts.commons.testing.DummyData 8 | import com.karntrehan.posts.core.testing.TestScheduler 9 | import com.karntrehan.posts.commons.data.local.PostDb 10 | import org.junit.Before 11 | import org.junit.Rule 12 | import org.junit.Test 13 | import org.junit.runner.RunWith 14 | 15 | /** 16 | * Tests for [DetailsLocalData] 17 | **/ 18 | @RunWith(AndroidJUnit4::class) 19 | class DetailsLocalDataTest { 20 | private lateinit var postDb: PostDb 21 | 22 | private val detailsLocalData: DetailsLocalData by lazy { 23 | DetailsLocalData( 24 | postDb, 25 | TestScheduler() 26 | ) 27 | } 28 | 29 | //Necessary for Room insertions to work 30 | @get:Rule 31 | val instantTaskExecutorRule = InstantTaskExecutorRule() 32 | 33 | private val postId = 1 34 | private val userId = 1 35 | private val dummyComments = listOf(DummyData.Comment(postId, 1), DummyData.Comment(postId, 2)) 36 | 37 | @Before 38 | fun init() { 39 | postDb = Room 40 | .inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().context, PostDb::class.java) 41 | .allowMainThreadQueries() 42 | .build() 43 | 44 | /*Need to insert users and posts, in that order, before testing 45 | or else ForeignKey constraint fails on inserts*/ 46 | val dummyUsers = listOf(DummyData.User(userId)) 47 | val dummyPosts = listOf(DummyData.Post(userId, postId)) 48 | postDb.userDao().upsertAll(dummyUsers) 49 | postDb.postDao().upsertAll(dummyPosts) 50 | } 51 | 52 | /** 53 | * Test that [DetailsLocalData.getCommentsForPost] returns correct values 54 | * */ 55 | @Test 56 | fun testGetCommentsForPost() { 57 | postDb.commentDao().upsertAll(dummyComments) 58 | detailsLocalData.getCommentsForPost(postId).test().assertValue(dummyComments) 59 | } 60 | 61 | /** 62 | * Test that [DetailsLocalData.saveComments] actually inserts the comments into the database 63 | * */ 64 | @Test 65 | fun testSaveComments() { 66 | detailsLocalData.saveComments(dummyComments) 67 | val comments = postDb.commentDao().getForPost(postId) 68 | comments.test().assertNoErrors().assertValue(dummyComments) 69 | } 70 | 71 | } -------------------------------------------------------------------------------- /posts/src/androidTest/java/com/karntrehan/posts/list/model/ListLocalDataTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.model 2 | 3 | import androidx.room.Room 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import com.karntrehan.posts.commons.testing.DummyData 7 | import com.karntrehan.posts.core.testing.TestScheduler 8 | import com.karntrehan.posts.commons.data.local.PostDb 9 | import org.junit.After 10 | import org.junit.Before 11 | import org.junit.runner.RunWith 12 | import org.junit.Rule 13 | import org.junit.Test 14 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 15 | 16 | /** 17 | * 18 | * Test for [ListLocalData] 19 | * Needs to be an instrumented test because Room needs to be tested on a physical device: 20 | * https://developer.android.com/training/data-storage/room/testing-db.html#android 21 | * 22 | * */ 23 | @RunWith(AndroidJUnit4::class) 24 | class ListLocalDataTest { 25 | 26 | private lateinit var postDb: PostDb 27 | 28 | private val listLocalData: ListLocalData by lazy { ListLocalData(postDb, TestScheduler()) } 29 | 30 | //Necessary for Room insertions to work 31 | @get:Rule 32 | val instantTaskExecutorRule = InstantTaskExecutorRule() 33 | 34 | private val dummyUsers = listOf(DummyData.User(1), DummyData.User(2)) 35 | private val dummyPosts = listOf(DummyData.Post(1, 1), DummyData.Post(2, 2)) 36 | 37 | @Before 38 | fun init() { 39 | postDb = Room 40 | .inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().context, PostDb::class.java) 41 | .allowMainThreadQueries() 42 | .build() 43 | } 44 | 45 | /** 46 | * Test that [ListLocalData.getPostsWithUsers] fetches the posts and users in the database 47 | * */ 48 | @Test 49 | fun testGetPostsWithUsers() { 50 | val postsWithUser = listOf(DummyData.PostWithUser(1), DummyData.PostWithUser(2)) 51 | 52 | postDb.userDao().upsertAll(dummyUsers) 53 | postDb.postDao().upsertAll(dummyPosts) 54 | 55 | listLocalData.getPostsWithUsers().test().assertValue(postsWithUser) 56 | } 57 | 58 | 59 | /** 60 | * Test that [ListLocalData.saveUsersAndPosts] saves the passed lists into the database 61 | * */ 62 | @Test 63 | fun saveUsersAndPosts() { 64 | 65 | listLocalData.saveUsersAndPosts(dummyUsers, dummyPosts) 66 | 67 | val users = postDb.userDao().getAll() 68 | users.test().assertNoErrors().assertValue(dummyUsers) 69 | 70 | val posts = postDb.postDao().getAll() 71 | posts.test().assertNoErrors().assertValue(dummyPosts) 72 | } 73 | 74 | @After 75 | fun clean() { 76 | postDb.close() 77 | } 78 | } -------------------------------------------------------------------------------- /posts/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/PostDH.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons 2 | 3 | import com.karntrehan.posts.core.application.CoreApp 4 | import com.karntrehan.posts.details.di.DaggerDetailsComponent 5 | import com.karntrehan.posts.details.di.DetailsComponent 6 | import com.karntrehan.posts.list.di.DaggerListComponent 7 | import com.karntrehan.posts.list.di.ListComponent 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | object PostDH { 12 | private var listComponent: ListComponent? = null 13 | private var detailsComponent: DetailsComponent? = null 14 | 15 | fun listComponent(): ListComponent { 16 | if (listComponent == null) 17 | listComponent = DaggerListComponent.builder().coreComponent(CoreApp.coreComponent).build() 18 | return listComponent as ListComponent 19 | } 20 | 21 | fun destroyListComponent() { 22 | listComponent = null 23 | } 24 | 25 | fun detailsComponent(): DetailsComponent { 26 | if (detailsComponent == null) 27 | detailsComponent = DaggerDetailsComponent.builder().listComponent(listComponent()).build() 28 | return detailsComponent as DetailsComponent 29 | } 30 | 31 | fun destroyDetailsComponent() { 32 | detailsComponent = null 33 | } 34 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/PostWithData.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.room.PrimaryKey 5 | import android.os.Parcelable 6 | import kotlinx.android.parcel.Parcelize 7 | 8 | @Parcelize 9 | @SuppressLint("ParcelCreator") 10 | data class PostWithUser(@PrimaryKey val postId: Int, 11 | val postTitle: String, 12 | val postBody: String, 13 | val userName: String) : Parcelable { 14 | 15 | fun getFormattedPostBody(): String { 16 | return if (postBody.length <= 70) 17 | postBody 18 | else 19 | postBody.substring(0, 67) + "..." 20 | } 21 | 22 | fun getAvatarPhoto() = "https://api.adorable.io/avatars/64/$userName.png" 23 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/local/Comment.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.local 2 | 3 | import androidx.room.Entity 4 | import androidx.room.ForeignKey 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | import com.google.gson.annotations.SerializedName 8 | 9 | @Entity(foreignKeys = [(ForeignKey(entity = Post::class, parentColumns = arrayOf("postId"), 10 | childColumns = arrayOf("postId"), onDelete = ForeignKey.CASCADE))], 11 | indices = [(Index("postId"))]) 12 | data class Comment(@SerializedName("postId") val postId: Int, 13 | @SerializedName("id") @PrimaryKey val id: Int, 14 | @SerializedName("name") val name: String, 15 | @SerializedName("email") val email: String, 16 | @SerializedName("body") val body: String) -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/local/CommentDao.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.local 2 | 3 | import androidx.room.* 4 | import io.reactivex.Flowable 5 | 6 | @Dao 7 | interface CommentDao { 8 | 9 | @Query("SELECT * from comment where postId = :postId") 10 | fun getForPost(postId: Int): Flowable> 11 | 12 | @Insert(onConflict = OnConflictStrategy.REPLACE) 13 | fun upsertAll(comments: List) 14 | 15 | @Delete 16 | fun delete(comment: Comment) 17 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/local/Post.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.local 2 | 3 | import androidx.room.Entity 4 | import androidx.room.ForeignKey 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | import com.google.gson.annotations.SerializedName 8 | 9 | @Entity(foreignKeys = [(ForeignKey(entity = User::class, parentColumns = ["id"], 10 | childColumns = ["userId"], onDelete = ForeignKey.CASCADE))], 11 | indices = [(Index("userId"))]) 12 | data class Post(@SerializedName("userId") val userId: Int, 13 | @SerializedName("id") @PrimaryKey val postId: Int, 14 | @SerializedName("title") val postTitle: String, 15 | @SerializedName("body") val postBody: String) -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/local/PostDao.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.local 2 | 3 | import androidx.room.* 4 | import com.karntrehan.posts.commons.data.PostWithUser 5 | import io.reactivex.Flowable 6 | 7 | 8 | @Dao 9 | interface PostDao { 10 | @Query("SELECT post.postId AS postId, post.postTitle AS postTitle ,post.postBody AS postBody, user.userName as userName FROM post, user WHERE post.userId= user.id") 11 | fun getAllPostsWithUser(): Flowable> 12 | 13 | 14 | @Insert(onConflict = OnConflictStrategy.REPLACE) 15 | fun upsertAll(posts: List) 16 | 17 | @Delete 18 | fun delete(post: Post) 19 | 20 | @Query("SELECT * FROM post") 21 | fun getAll(): Flowable> 22 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/local/PostDb.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.local 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | 6 | @Database(entities = [Post::class, User::class, Comment::class], version = 1,exportSchema = false) 7 | abstract class PostDb : RoomDatabase() { 8 | abstract fun postDao(): PostDao 9 | abstract fun userDao(): UserDao 10 | abstract fun commentDao(): CommentDao 11 | } 12 | -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/local/User.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.local 2 | 3 | import androidx.room.Entity 4 | import androidx.room.Index 5 | import androidx.room.PrimaryKey 6 | import com.google.gson.annotations.SerializedName 7 | 8 | @Entity(indices = [(Index("id"))]) 9 | data class User(@SerializedName("id") @PrimaryKey val id: Int, 10 | @SerializedName("name") val userName: String, 11 | @SerializedName("username") val userIdentity: String, 12 | @SerializedName("email") val userEmail: String) -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/local/UserDao.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.local 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import io.reactivex.Flowable 8 | 9 | @Dao 10 | interface UserDao { 11 | @Insert(onConflict = OnConflictStrategy.REPLACE) 12 | fun upsertAll(users: List) 13 | 14 | @Query("SELECT * FROM user") 15 | fun getAll(): Flowable> 16 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/data/remote/PostService.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.remote 2 | 3 | import com.karntrehan.posts.commons.data.local.Comment 4 | import com.karntrehan.posts.commons.data.local.Post 5 | import com.karntrehan.posts.commons.data.local.User 6 | import io.reactivex.Flowable 7 | import io.reactivex.Single 8 | import retrofit2.http.GET 9 | import retrofit2.http.Query 10 | 11 | interface PostService { 12 | @GET("/posts/") 13 | fun getPosts(): Single> 14 | 15 | @GET("/users/") 16 | fun getUsers(): Single> 17 | 18 | @GET("/comments/") 19 | fun getComments(@Query("postId") postId: Int): Single> 20 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/commons/testing/DummyData.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.testing 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import com.karntrehan.posts.commons.data.PostWithUser 5 | import com.karntrehan.posts.commons.data.local.Comment 6 | import com.karntrehan.posts.commons.data.local.Post 7 | import com.karntrehan.posts.commons.data.local.User 8 | 9 | @VisibleForTesting(otherwise = VisibleForTesting.NONE) 10 | object DummyData { 11 | fun User(id: Int) = User(id, "username$id", "userIdentity$id", "email$id") 12 | fun Post(userId: Int, id: Int) = Post(userId, id, "title$id", "body$id") 13 | fun PostWithUser(id: Int) = PostWithUser(id, "title$id", "body$id", "username$id") 14 | fun Comment(postId: Int, id: Int) = Comment(postId,id,"name$id","email$id","body$id") 15 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/DetailsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.Build 7 | import android.os.Bundle 8 | import android.util.Log 9 | import android.view.View 10 | import android.widget.ImageView 11 | import android.widget.TextView 12 | import android.widget.Toast 13 | import androidx.core.app.ActivityOptionsCompat 14 | import androidx.core.util.Pair 15 | import androidx.core.view.ViewCompat 16 | import androidx.lifecycle.Observer 17 | import androidx.lifecycle.ViewModelProviders 18 | import com.karntrehan.posts.R 19 | import com.karntrehan.posts.commons.PostDH 20 | import com.karntrehan.posts.commons.data.PostWithUser 21 | import com.karntrehan.posts.commons.data.local.Comment 22 | import com.karntrehan.posts.core.application.BaseActivity 23 | import com.karntrehan.posts.details.exceptions.DetailsExceptions 24 | import com.karntrehan.posts.details.viewmodel.DetailsViewModel 25 | import com.karntrehan.posts.details.viewmodel.DetailsViewModelFactory 26 | import com.mpaani.core.networking.Outcome 27 | import com.squareup.picasso.Picasso 28 | import kotlinx.android.synthetic.main.activity_details.* 29 | import java.io.IOException 30 | import javax.inject.Inject 31 | 32 | 33 | class DetailsActivity : BaseActivity(), DetailsAdapter.Interaction { 34 | 35 | companion object { 36 | private const val SELECTED_POST = "post" 37 | //Transitions 38 | private const val TITLE_TRANSITION_NAME = "title_transition" 39 | private const val BODY_TRANSITION_NAME = "body_transition" 40 | private const val AUTHOR_TRANSITION_NAME = "author_transition" 41 | private const val AVATAR_TRANSITION_NAME = "avatar_transition" 42 | 43 | fun start( 44 | context: Context, 45 | post: PostWithUser, 46 | tvTitle: TextView, 47 | tvBody: TextView, 48 | tvAuthorName: TextView, 49 | ivAvatar: ImageView 50 | ) { 51 | val intent = Intent(context, DetailsActivity::class.java) 52 | intent.putExtra(SELECTED_POST, post) 53 | 54 | //Transitions 55 | intent.putExtra(TITLE_TRANSITION_NAME, ViewCompat.getTransitionName(tvTitle)) 56 | intent.putExtra(BODY_TRANSITION_NAME, ViewCompat.getTransitionName(tvBody)) 57 | intent.putExtra(AUTHOR_TRANSITION_NAME, ViewCompat.getTransitionName(tvAuthorName)) 58 | intent.putExtra(AVATAR_TRANSITION_NAME, ViewCompat.getTransitionName(ivAvatar)) 59 | 60 | val p1 = Pair.create(tvTitle as View, ViewCompat.getTransitionName(tvTitle)) 61 | val p2 = Pair.create(tvBody as View, ViewCompat.getTransitionName(tvBody)) 62 | val p3 = Pair.create(tvAuthorName as View, ViewCompat.getTransitionName(tvAuthorName)) 63 | val p4 = Pair.create(ivAvatar as View, ViewCompat.getTransitionName(ivAvatar)) 64 | val options = ActivityOptionsCompat.makeSceneTransitionAnimation( 65 | context as Activity, 66 | p1, 67 | p2, 68 | p3, 69 | p4 70 | ) 71 | 72 | context.startActivity(intent, options.toBundle()) 73 | } 74 | 75 | fun start(context: Context, postId: Int) { 76 | val intent = Intent(context, DetailsActivity::class.java) 77 | intent.putExtra(SELECTED_POST, postId) 78 | context.startActivity(intent) 79 | } 80 | } 81 | 82 | private val TAG = "DetailsActivity" 83 | private var selectedPost: PostWithUser? = null 84 | private val context: Context by lazy { this } 85 | 86 | private val component by lazy { PostDH.detailsComponent() } 87 | 88 | val adapter: DetailsAdapter by lazy { DetailsAdapter(this) } 89 | 90 | @Inject 91 | lateinit var viewModelFactory: DetailsViewModelFactory 92 | 93 | @Inject 94 | lateinit var picasso: Picasso 95 | 96 | private val viewModel: DetailsViewModel by lazy { 97 | ViewModelProviders.of(this, viewModelFactory).get(DetailsViewModel::class.java) 98 | } 99 | 100 | override fun onCreate(savedInstanceState: Bundle?) { 101 | super.onCreate(savedInstanceState) 102 | setContentView(R.layout.activity_details) 103 | component.inject(this) 104 | getIntentData() 105 | 106 | srlComments.setOnRefreshListener { viewModel.refreshCommentsFor(selectedPost?.postId) } 107 | } 108 | 109 | private fun getIntentData() { 110 | if (!intent.hasExtra(SELECTED_POST)) { 111 | Log.d(TAG, "getIntentData: could not find selected post") 112 | finish() 113 | return 114 | } 115 | 116 | selectedPost = intent.getParcelableExtra(SELECTED_POST) 117 | tvTitle.text = selectedPost?.postTitle 118 | tvBody.text = selectedPost?.postBody 119 | tvAuthorName.text = selectedPost?.userName 120 | picasso.load(selectedPost?.getAvatarPhoto()).into(ivAvatar) 121 | 122 | handleTransition(intent.extras) 123 | 124 | rvComments.adapter = adapter 125 | 126 | viewModel.loadCommentsFor(selectedPost?.postId) 127 | observeData() 128 | } 129 | 130 | private fun handleTransition(extras: Bundle?) { 131 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 132 | tvTitle.transitionName = extras?.getString(TITLE_TRANSITION_NAME) 133 | tvBody.transitionName = extras?.getString(BODY_TRANSITION_NAME) 134 | tvAuthorName.transitionName = extras?.getString(AUTHOR_TRANSITION_NAME) 135 | ivAvatar.transitionName = extras?.getString(AVATAR_TRANSITION_NAME) 136 | } 137 | } 138 | 139 | private fun observeData() { 140 | viewModel.commentsOutcome.observe(this, Observer>> { outcome -> 141 | Log.d(TAG, "initiateDataListener: " + outcome.toString()) 142 | when (outcome) { 143 | 144 | is Outcome.Progress -> srlComments.isRefreshing = outcome.loading 145 | 146 | is Outcome.Success -> { 147 | Log.d(TAG, "observeData: Successfully loaded data") 148 | tvCommentError.visibility = View.GONE 149 | adapter.swapData(outcome.data) 150 | } 151 | 152 | is Outcome.Failure -> { 153 | when (outcome.e) { 154 | DetailsExceptions.NoComments() -> tvCommentError.visibility = 155 | View.VISIBLE 156 | IOException() -> Toast.makeText( 157 | context, 158 | R.string.need_internet_posts, 159 | Toast.LENGTH_LONG 160 | ).show() 161 | else -> Toast.makeText( 162 | context, 163 | R.string.failed_post_try_again, 164 | Toast.LENGTH_LONG 165 | ).show() 166 | } 167 | } 168 | 169 | } 170 | }) 171 | } 172 | 173 | override fun commentClicked(model: Comment) { 174 | Log.d(TAG, "Comment clicked: $model") 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/DetailsAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.View.OnClickListener 6 | import android.view.ViewGroup 7 | import androidx.recyclerview.widget.DiffUtil 8 | import androidx.recyclerview.widget.ListAdapter 9 | import androidx.recyclerview.widget.RecyclerView 10 | import com.karntrehan.posts.R 11 | import com.karntrehan.posts.commons.data.local.Comment 12 | import kotlinx.android.synthetic.main.comment_item.view.* 13 | 14 | class DetailsAdapter(private val interaction: Interaction? = null) : 15 | ListAdapter(CommentDC()) { 16 | 17 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = DetailsViewHolder( 18 | LayoutInflater.from(parent.context) 19 | .inflate(R.layout.comment_item, parent, false), interaction 20 | ) 21 | 22 | override fun onBindViewHolder(holder: DetailsViewHolder, position: Int) = holder.bind(getItem(position)) 23 | 24 | fun swapData(data: List) { 25 | submitList(data.toMutableList()) 26 | } 27 | 28 | inner class DetailsViewHolder(itemView: View, private val interaction: Interaction?) 29 | : RecyclerView.ViewHolder(itemView), OnClickListener { 30 | 31 | init { 32 | itemView.setOnClickListener(this) 33 | } 34 | 35 | override fun onClick(v: View?) { 36 | val clicked = getItem(adapterPosition) 37 | interaction?.commentClicked(clicked) 38 | } 39 | 40 | fun bind(item: Comment) = with(itemView) { 41 | tvComment.text = item.body 42 | tvAuthor.text = "- ${item.name}" 43 | } 44 | } 45 | 46 | interface Interaction { 47 | fun commentClicked(model: Comment) 48 | } 49 | 50 | private class CommentDC : DiffUtil.ItemCallback() { 51 | override fun areItemsTheSame( 52 | oldItem: Comment, 53 | newItem: Comment 54 | ) = oldItem.id == newItem.id 55 | 56 | override fun areContentsTheSame( 57 | oldItem: Comment, 58 | newItem: Comment 59 | ) = oldItem == newItem 60 | } 61 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/di/DetailsComponent.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.di 2 | 3 | import com.karntrehan.posts.commons.data.local.PostDb 4 | import com.karntrehan.posts.commons.data.remote.PostService 5 | import com.karntrehan.posts.core.networking.Scheduler 6 | import com.karntrehan.posts.details.DetailsActivity 7 | import com.karntrehan.posts.details.model.DetailsDataContract 8 | import com.karntrehan.posts.details.model.DetailsLocalData 9 | import com.karntrehan.posts.details.model.DetailsRemoteData 10 | import com.karntrehan.posts.details.model.DetailsRepository 11 | import com.karntrehan.posts.details.viewmodel.DetailsViewModelFactory 12 | import com.karntrehan.posts.list.di.ListComponent 13 | import dagger.Component 14 | import dagger.Module 15 | import dagger.Provides 16 | import io.reactivex.disposables.CompositeDisposable 17 | 18 | @DetailsScope 19 | @Component(dependencies = [ListComponent::class], modules = [DetailsModule::class]) 20 | interface DetailsComponent { 21 | fun inject(detailsActivity: DetailsActivity) 22 | } 23 | 24 | @Module 25 | class DetailsModule { 26 | 27 | /*ViewModel*/ 28 | @Provides 29 | @DetailsScope 30 | fun detailsViewModelFactory(repo: DetailsDataContract.Repository, compositeDisposable: CompositeDisposable): DetailsViewModelFactory { 31 | return DetailsViewModelFactory(repo, compositeDisposable) 32 | } 33 | 34 | /*Repository*/ 35 | @Provides 36 | @DetailsScope 37 | fun detailsRepo(local: DetailsDataContract.Local, remote: DetailsDataContract.Remote, scheduler: Scheduler, compositeDisposable: CompositeDisposable) 38 | : DetailsDataContract.Repository = DetailsRepository(local, remote, scheduler, compositeDisposable) 39 | 40 | @Provides 41 | @DetailsScope 42 | fun remoteData(postService: PostService): DetailsDataContract.Remote = DetailsRemoteData(postService) 43 | 44 | @Provides 45 | @DetailsScope 46 | fun localData(postDb: PostDb, scheduler: Scheduler): DetailsDataContract.Local = DetailsLocalData(postDb, scheduler) 47 | 48 | @Provides 49 | @DetailsScope 50 | fun compositeDisposable(): CompositeDisposable = CompositeDisposable() 51 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/di/DetailsScope.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.di 2 | 3 | import javax.inject.Scope 4 | 5 | @Scope 6 | @Retention(AnnotationRetention.RUNTIME) 7 | annotation class DetailsScope -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/exceptions/DetailsExceptions.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.exceptions 2 | 3 | interface DetailsExceptions { 4 | class NoComments : Exception() 5 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/model/DetailsDataContract.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.model 2 | 3 | import com.karntrehan.posts.commons.data.local.Comment 4 | import com.mpaani.core.networking.Outcome 5 | import io.reactivex.Flowable 6 | import io.reactivex.Single 7 | import io.reactivex.subjects.PublishSubject 8 | 9 | interface DetailsDataContract { 10 | interface Repository { 11 | val commentsFetchOutcome: PublishSubject>> 12 | fun fetchCommentsFor(postId: Int?) 13 | fun refreshComments(postId: Int) 14 | fun saveCommentsForPost(comments: List) 15 | fun handleError(error: Throwable) 16 | } 17 | 18 | interface Local { 19 | fun getCommentsForPost(postId: Int): Flowable> 20 | fun saveComments(comments: List) 21 | } 22 | 23 | interface Remote { 24 | fun getCommentsForPost(postId: Int): Single> 25 | } 26 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/model/DetailsLocalData.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.model 2 | 3 | import com.karntrehan.posts.commons.data.local.Comment 4 | import com.karntrehan.posts.commons.data.local.PostDb 5 | import com.karntrehan.posts.core.extensions.performOnBack 6 | import com.karntrehan.posts.core.networking.Scheduler 7 | import io.reactivex.Completable 8 | import io.reactivex.Flowable 9 | 10 | class DetailsLocalData(private val postDb: PostDb, private val scheduler: Scheduler) : DetailsDataContract.Local { 11 | 12 | override fun getCommentsForPost(postId: Int): Flowable> { 13 | return postDb.commentDao().getForPost(postId) 14 | } 15 | 16 | override fun saveComments(comments: List) { 17 | Completable.fromAction { 18 | postDb.commentDao().upsertAll(comments) 19 | } 20 | .performOnBack(scheduler) 21 | .subscribe() 22 | } 23 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/model/DetailsRemoteData.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.model 2 | 3 | import com.karntrehan.posts.commons.data.remote.PostService 4 | 5 | class DetailsRemoteData(private val postService: PostService) : DetailsDataContract.Remote { 6 | 7 | override fun getCommentsForPost(postId: Int) = postService.getComments(postId) 8 | 9 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/model/DetailsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.model 2 | 3 | import com.karntrehan.posts.commons.data.local.Comment 4 | import com.karntrehan.posts.core.extensions.* 5 | import com.karntrehan.posts.core.networking.Scheduler 6 | import com.karntrehan.posts.core.networking.synk.Synk 7 | import com.karntrehan.posts.core.networking.synk.SynkKeys 8 | import com.karntrehan.posts.details.exceptions.DetailsExceptions 9 | import com.mpaani.core.networking.Outcome 10 | import io.reactivex.disposables.CompositeDisposable 11 | import io.reactivex.subjects.PublishSubject 12 | import java.util.concurrent.TimeUnit 13 | 14 | class DetailsRepository( 15 | private val local: DetailsDataContract.Local, 16 | private val remote: DetailsDataContract.Remote, 17 | private val scheduler: Scheduler, 18 | private val compositeDisposable: CompositeDisposable 19 | ) : DetailsDataContract.Repository { 20 | 21 | override val commentsFetchOutcome: PublishSubject>> = 22 | PublishSubject.create>>() 23 | 24 | override fun fetchCommentsFor(postId: Int?) { 25 | if (postId == null) 26 | return 27 | 28 | commentsFetchOutcome.loading(true) 29 | local.getCommentsForPost(postId) 30 | .performOnBackOutOnMain(scheduler) 31 | .doAfterNext { 32 | if (Synk.shouldSync(SynkKeys.POST_DETAILS + "_" + postId, 2, TimeUnit.HOURS)) 33 | refreshComments(postId) 34 | } 35 | .subscribe({ comments -> 36 | commentsFetchOutcome.success(comments) 37 | }, { error -> handleError(error) }) 38 | .addTo(compositeDisposable) 39 | } 40 | 41 | override fun refreshComments(postId: Int) { 42 | commentsFetchOutcome.loading(true) 43 | remote.getCommentsForPost(postId) 44 | .performOnBackOutOnMain(scheduler) 45 | .subscribe( 46 | { comments -> saveCommentsForPost(comments) }, 47 | { error -> handleError(error) }) 48 | .addTo(compositeDisposable) 49 | } 50 | 51 | override fun saveCommentsForPost(comments: List) { 52 | if (comments.isNotEmpty()) { 53 | local.saveComments(comments) 54 | } else 55 | commentsFetchOutcome.failed(DetailsExceptions.NoComments()) 56 | } 57 | 58 | override fun handleError(error: Throwable) { 59 | commentsFetchOutcome.failed(error) 60 | } 61 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/viewmodel/DetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.viewmodel 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.ViewModel 5 | import com.karntrehan.posts.commons.PostDH 6 | import com.karntrehan.posts.commons.data.local.Comment 7 | import com.karntrehan.posts.core.extensions.toLiveData 8 | import com.karntrehan.posts.details.model.DetailsDataContract 9 | import com.mpaani.core.networking.Outcome 10 | import io.reactivex.disposables.CompositeDisposable 11 | 12 | class DetailsViewModel(private val repo: DetailsDataContract.Repository, private val compositeDisposable: CompositeDisposable) : ViewModel() { 13 | 14 | val commentsOutcome: LiveData>> by lazy { 15 | repo.commentsFetchOutcome.toLiveData(compositeDisposable) 16 | } 17 | 18 | fun loadCommentsFor(postId: Int?) { 19 | repo.fetchCommentsFor(postId) 20 | } 21 | 22 | fun refreshCommentsFor(postId: Int?) { 23 | if (postId != null) 24 | repo.refreshComments(postId) 25 | } 26 | 27 | override fun onCleared() { 28 | super.onCleared() 29 | //clear the disposables when the viewmodel is cleared 30 | compositeDisposable.clear() 31 | PostDH.destroyDetailsComponent() 32 | } 33 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/details/viewmodel/DetailsViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.karntrehan.posts.details.model.DetailsDataContract 6 | import io.reactivex.disposables.CompositeDisposable 7 | 8 | @Suppress("UNCHECKED_CAST") 9 | class DetailsViewModelFactory(private val repository: DetailsDataContract.Repository, private val compositeDisposable: CompositeDisposable) : 10 | ViewModelProvider.Factory { 11 | override fun create(modelClass: Class): T { 12 | return DetailsViewModel(repository,compositeDisposable) as T 13 | } 14 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/ListActivity.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.widget.ImageView 7 | import android.widget.TextView 8 | import android.widget.Toast 9 | import androidx.lifecycle.Observer 10 | import androidx.lifecycle.ViewModelProviders 11 | import com.karntrehan.posts.R 12 | import com.karntrehan.posts.commons.PostDH 13 | import com.karntrehan.posts.commons.data.PostWithUser 14 | import com.karntrehan.posts.core.application.BaseActivity 15 | import com.karntrehan.posts.details.DetailsActivity 16 | import com.karntrehan.posts.list.viewmodel.ListViewModel 17 | import com.karntrehan.posts.list.viewmodel.ListViewModelFactory 18 | import com.mpaani.core.networking.Outcome 19 | import kotlinx.android.synthetic.main.activity_list.* 20 | import java.io.IOException 21 | import javax.inject.Inject 22 | 23 | class ListActivity : BaseActivity(), PostListAdapter.Interaction { 24 | 25 | private val component by lazy { PostDH.listComponent() } 26 | 27 | @Inject 28 | lateinit var viewModelFactory: ListViewModelFactory 29 | 30 | @Inject 31 | lateinit var adapter: PostListAdapter 32 | 33 | private val viewModel: ListViewModel by lazy { 34 | ViewModelProviders.of(this, viewModelFactory).get(ListViewModel::class.java) 35 | } 36 | 37 | private val context: Context by lazy { this } 38 | 39 | private val TAG = "ListActivity" 40 | 41 | override fun onCreate(savedInstanceState: Bundle?) { 42 | super.onCreate(savedInstanceState) 43 | setContentView(R.layout.activity_list) 44 | component.inject(this) 45 | 46 | adapter.interaction = this 47 | rvPosts.adapter = adapter 48 | srlPosts.setOnRefreshListener { viewModel.refreshPosts() } 49 | 50 | viewModel.getPosts() 51 | initiateDataListener() 52 | } 53 | 54 | private fun initiateDataListener() { 55 | //Observe the outcome and update state of the screen accordingly 56 | viewModel.postsOutcome.observe(this, Observer>> { outcome -> 57 | Log.d(TAG, "initiateDataListener: $outcome") 58 | when (outcome) { 59 | 60 | is Outcome.Progress -> srlPosts.isRefreshing = outcome.loading 61 | 62 | is Outcome.Success -> { 63 | Log.d(TAG, "initiateDataListener: Successfully loaded data") 64 | adapter.swapData(outcome.data) 65 | } 66 | 67 | is Outcome.Failure -> { 68 | 69 | if (outcome.e is IOException) 70 | Toast.makeText( 71 | context, 72 | R.string.need_internet_posts, 73 | Toast.LENGTH_LONG 74 | ).show() 75 | else 76 | Toast.makeText( 77 | context, 78 | R.string.failed_post_try_again, 79 | Toast.LENGTH_LONG 80 | ).show() 81 | } 82 | 83 | } 84 | }) 85 | } 86 | 87 | override fun postClicked( 88 | post: PostWithUser, 89 | tvTitle: TextView, 90 | tvBody: TextView, 91 | tvAuthorName: TextView, 92 | ivAvatar: ImageView 93 | ) { 94 | DetailsActivity.start(context, post, tvTitle, tvBody, tvAuthorName, ivAvatar) 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/PostListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list 2 | 3 | import android.view.LayoutInflater 4 | import android.view.View 5 | import android.view.View.OnClickListener 6 | import android.view.ViewGroup 7 | import android.widget.ImageView 8 | import android.widget.TextView 9 | import androidx.core.view.ViewCompat 10 | import androidx.recyclerview.widget.DiffUtil 11 | import androidx.recyclerview.widget.ListAdapter 12 | import androidx.recyclerview.widget.RecyclerView 13 | import com.karntrehan.posts.R 14 | import com.karntrehan.posts.commons.data.PostWithUser 15 | import com.squareup.picasso.Picasso 16 | import kotlinx.android.synthetic.main.post_item.view.* 17 | 18 | class PostListAdapter(private val picasso: Picasso) 19 | : ListAdapter(PostWithUserDC()) { 20 | 21 | var interaction: Interaction? = null 22 | 23 | override fun onCreateViewHolder( 24 | parent: ViewGroup, 25 | viewType: Int 26 | ) = ListViewHolder(LayoutInflater.from(parent.context) 27 | .inflate(R.layout.post_item, parent, false), interaction) 28 | 29 | override fun onBindViewHolder( 30 | holder: ListViewHolder, 31 | position: Int 32 | ) = holder.bind(getItem(position), picasso) 33 | 34 | fun swapData(data: List) { 35 | submitList(data.toMutableList()) 36 | } 37 | 38 | inner class ListViewHolder( 39 | itemView: View, 40 | private val interaction: Interaction? 41 | ) : RecyclerView.ViewHolder(itemView), OnClickListener { 42 | 43 | init { 44 | itemView.setOnClickListener(this) 45 | } 46 | 47 | override fun onClick(v: View?) { 48 | val clicked = getItem(adapterPosition) 49 | interaction?.postClicked(clicked, itemView.tvTitle, itemView.tvBody, itemView.tvAuthorName, itemView.ivAvatar) 50 | } 51 | 52 | fun bind(item: PostWithUser, picasso: Picasso) = with(itemView) { 53 | tvTitle.text = item.postTitle 54 | tvBody.text = item.getFormattedPostBody() 55 | tvAuthorName.text = item.userName 56 | picasso.load(item.getAvatarPhoto()) 57 | .into(itemView.ivAvatar) 58 | 59 | //SharedItem transition 60 | ViewCompat.setTransitionName(tvTitle, item.postTitle) 61 | ViewCompat.setTransitionName(tvBody, item.postBody) 62 | ViewCompat.setTransitionName(tvAuthorName, item.userName) 63 | ViewCompat.setTransitionName(ivAvatar, item.getAvatarPhoto()) 64 | } 65 | } 66 | 67 | interface Interaction { 68 | fun postClicked( 69 | post: PostWithUser, 70 | tvTitle: TextView, 71 | tvBody: TextView, 72 | tvAuthorName: TextView, 73 | ivAvatar: ImageView) 74 | } 75 | 76 | private class PostWithUserDC : DiffUtil.ItemCallback() { 77 | override fun areItemsTheSame(oldItem: PostWithUser, newItem: PostWithUser) = oldItem.postId == newItem.postId 78 | 79 | override fun areContentsTheSame(oldItem: PostWithUser, newItem: PostWithUser) = oldItem == newItem 80 | } 81 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/di/ListComponent.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.di 2 | 3 | import androidx.room.Room 4 | import android.content.Context 5 | import com.karntrehan.posts.commons.data.local.PostDb 6 | import com.karntrehan.posts.commons.data.remote.PostService 7 | import com.karntrehan.posts.core.constants.Constants 8 | import com.karntrehan.posts.core.di.CoreComponent 9 | import com.karntrehan.posts.core.networking.Scheduler 10 | import com.karntrehan.posts.list.ListActivity 11 | import com.karntrehan.posts.list.PostListAdapter 12 | import com.karntrehan.posts.list.model.ListDataContract 13 | import com.karntrehan.posts.list.model.ListLocalData 14 | import com.karntrehan.posts.list.model.ListRemoteData 15 | import com.karntrehan.posts.list.model.ListRepository 16 | import com.karntrehan.posts.list.viewmodel.ListViewModelFactory 17 | import com.squareup.picasso.Picasso 18 | import dagger.Component 19 | import dagger.Module 20 | import dagger.Provides 21 | import io.reactivex.disposables.CompositeDisposable 22 | import retrofit2.Retrofit 23 | 24 | @ListScope 25 | @Component(dependencies = [CoreComponent::class], modules = [ListModule::class]) 26 | interface ListComponent { 27 | 28 | //Expose to dependent components 29 | fun postDb(): PostDb 30 | 31 | fun postService(): PostService 32 | fun picasso(): Picasso 33 | fun scheduler(): Scheduler 34 | 35 | fun inject(listActivity: ListActivity) 36 | } 37 | 38 | @Module 39 | @ListScope 40 | class ListModule { 41 | 42 | /*Adapter*/ 43 | @Provides 44 | @ListScope 45 | fun adapter(picasso: Picasso): PostListAdapter = PostListAdapter(picasso) 46 | 47 | /*ViewModel*/ 48 | @Provides 49 | @ListScope 50 | fun listViewModelFactory(repository: ListDataContract.Repository,compositeDisposable: CompositeDisposable): ListViewModelFactory = ListViewModelFactory(repository,compositeDisposable) 51 | 52 | /*Repository*/ 53 | @Provides 54 | @ListScope 55 | fun listRepo(local: ListDataContract.Local, remote: ListDataContract.Remote, scheduler: Scheduler, compositeDisposable: CompositeDisposable): ListDataContract.Repository = ListRepository(local, remote, scheduler, compositeDisposable) 56 | 57 | @Provides 58 | @ListScope 59 | fun remoteData(postService: PostService): ListDataContract.Remote = ListRemoteData(postService) 60 | 61 | @Provides 62 | @ListScope 63 | fun localData(postDb: PostDb, scheduler: Scheduler): ListDataContract.Local = ListLocalData(postDb, scheduler) 64 | 65 | @Provides 66 | @ListScope 67 | fun compositeDisposable(): CompositeDisposable = CompositeDisposable() 68 | 69 | /*Parent providers to dependents*/ 70 | @Provides 71 | @ListScope 72 | fun postDb(context: Context): PostDb = Room.databaseBuilder(context, PostDb::class.java, Constants.Posts.DB_NAME).build() 73 | 74 | @Provides 75 | @ListScope 76 | fun postService(retrofit: Retrofit): PostService = retrofit.create(PostService::class.java) 77 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/di/ListScope.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.di 2 | 3 | import javax.inject.Scope 4 | 5 | @Scope 6 | @Retention(AnnotationRetention.RUNTIME) 7 | annotation class ListScope -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/model/ListDataContract.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.model 2 | 3 | import com.karntrehan.posts.commons.data.PostWithUser 4 | import com.karntrehan.posts.commons.data.local.Post 5 | import com.karntrehan.posts.commons.data.local.User 6 | import com.mpaani.core.networking.Outcome 7 | import io.reactivex.Flowable 8 | import io.reactivex.Single 9 | import io.reactivex.subjects.PublishSubject 10 | 11 | interface ListDataContract { 12 | interface Repository { 13 | val postFetchOutcome: PublishSubject>> 14 | fun fetchPosts() 15 | fun refreshPosts() 16 | fun saveUsersAndPosts(users: List, posts: List) 17 | fun handleError(error: Throwable) 18 | } 19 | 20 | interface Local { 21 | fun getPostsWithUsers(): Flowable> 22 | fun saveUsersAndPosts(users: List, posts: List) 23 | } 24 | 25 | interface Remote { 26 | fun getUsers(): Single> 27 | fun getPosts(): Single> 28 | } 29 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/model/ListLocalData.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.model 2 | 3 | import com.karntrehan.posts.commons.data.PostWithUser 4 | import com.karntrehan.posts.commons.data.local.Post 5 | import com.karntrehan.posts.commons.data.local.PostDb 6 | import com.karntrehan.posts.commons.data.local.User 7 | import com.karntrehan.posts.core.extensions.performOnBack 8 | import com.karntrehan.posts.core.networking.Scheduler 9 | import io.reactivex.Completable 10 | import io.reactivex.Flowable 11 | 12 | class ListLocalData(private val postDb: PostDb, private val scheduler: Scheduler) : ListDataContract.Local { 13 | 14 | override fun getPostsWithUsers(): Flowable> { 15 | return postDb.postDao().getAllPostsWithUser() 16 | } 17 | 18 | override fun saveUsersAndPosts(users: List, posts: List) { 19 | Completable.fromAction { 20 | postDb.userDao().upsertAll(users) 21 | postDb.postDao().upsertAll(posts) 22 | } 23 | .performOnBack(scheduler) 24 | .subscribe() 25 | } 26 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/model/ListRemoteData.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.model 2 | 3 | import com.karntrehan.posts.commons.data.remote.PostService 4 | 5 | class ListRemoteData(private val postService: PostService) : ListDataContract.Remote { 6 | 7 | override fun getUsers() = postService.getUsers() 8 | 9 | override fun getPosts() = postService.getPosts() 10 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/model/ListRepository.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.model 2 | 3 | import com.karntrehan.posts.commons.data.PostWithUser 4 | import com.karntrehan.posts.commons.data.local.Post 5 | import com.karntrehan.posts.commons.data.local.User 6 | import com.karntrehan.posts.core.extensions.* 7 | import com.karntrehan.posts.core.networking.Scheduler 8 | import com.karntrehan.posts.core.networking.synk.Synk 9 | import com.karntrehan.posts.core.networking.synk.SynkKeys 10 | import com.mpaani.core.networking.Outcome 11 | import io.reactivex.Single 12 | import io.reactivex.disposables.CompositeDisposable 13 | import io.reactivex.functions.BiFunction 14 | import io.reactivex.subjects.PublishSubject 15 | import java.util.concurrent.TimeUnit 16 | 17 | 18 | class ListRepository( 19 | private val local: ListDataContract.Local, 20 | private val remote: ListDataContract.Remote, 21 | private val scheduler: Scheduler, 22 | private val compositeDisposable: CompositeDisposable 23 | ) : ListDataContract.Repository { 24 | 25 | override val postFetchOutcome: PublishSubject>> = 26 | PublishSubject.create>>() 27 | 28 | override fun fetchPosts() { 29 | postFetchOutcome.loading(true) 30 | //Observe changes to the db 31 | local.getPostsWithUsers() 32 | .performOnBackOutOnMain(scheduler) 33 | .doAfterNext { 34 | if (Synk.shouldSync(SynkKeys.POSTS_HOME, 2, TimeUnit.HOURS)) 35 | refreshPosts() 36 | } 37 | .subscribe({ postsWithUsers -> 38 | postFetchOutcome.success(postsWithUsers) 39 | }, { error -> handleError(error) }) 40 | .addTo(compositeDisposable) 41 | } 42 | 43 | override fun refreshPosts() { 44 | postFetchOutcome.loading(true) 45 | Single.zip( 46 | remote.getUsers(), 47 | remote.getPosts(), 48 | zipUsersAndPosts() 49 | ) 50 | .performOnBackOutOnMain(scheduler) 51 | .updateSynkStatus(key = SynkKeys.POSTS_HOME) 52 | .subscribe({}, { error -> handleError(error) }) 53 | .addTo(compositeDisposable) 54 | } 55 | 56 | private fun zipUsersAndPosts() = 57 | BiFunction, List, Unit> { users, posts -> 58 | saveUsersAndPosts(users, posts) 59 | } 60 | 61 | override fun saveUsersAndPosts(users: List, posts: List) { 62 | local.saveUsersAndPosts(users, posts) 63 | } 64 | 65 | override fun handleError(error: Throwable) { 66 | postFetchOutcome.failed(error) 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/viewmodel/ListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.viewmodel 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.ViewModel 5 | import com.karntrehan.posts.commons.PostDH 6 | import com.karntrehan.posts.commons.data.PostWithUser 7 | import com.karntrehan.posts.core.extensions.toLiveData 8 | import com.karntrehan.posts.list.model.ListDataContract 9 | import com.mpaani.core.networking.Outcome 10 | import io.reactivex.disposables.CompositeDisposable 11 | 12 | class ListViewModel(private val repo: ListDataContract.Repository, 13 | private val compositeDisposable: CompositeDisposable) : ViewModel() { 14 | 15 | val postsOutcome: LiveData>> by lazy { 16 | //Convert publish subject to livedata 17 | repo.postFetchOutcome.toLiveData(compositeDisposable) 18 | } 19 | 20 | fun getPosts() { 21 | if (postsOutcome.value == null) 22 | repo.fetchPosts() 23 | } 24 | 25 | fun refreshPosts() { 26 | repo.refreshPosts() 27 | } 28 | 29 | override fun onCleared() { 30 | super.onCleared() 31 | //clear the disposables when the viewmodel is cleared 32 | compositeDisposable.clear() 33 | PostDH.destroyListComponent() 34 | } 35 | } -------------------------------------------------------------------------------- /posts/src/main/java/com/karntrehan/posts/list/viewmodel/ListViewModelFactory.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.ViewModelProvider 5 | import com.karntrehan.posts.list.model.ListDataContract 6 | import io.reactivex.disposables.CompositeDisposable 7 | 8 | @Suppress("UNCHECKED_CAST") 9 | class ListViewModelFactory(private val repository: ListDataContract.Repository, private val compositeDisposable: CompositeDisposable) : 10 | ViewModelProvider.Factory { 11 | override fun create(modelClass: Class): T { 12 | return ListViewModel(repository, compositeDisposable) as T 13 | } 14 | } -------------------------------------------------------------------------------- /posts/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /posts/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /posts/src/main/res/layout/activity_details.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | 22 | 23 | 30 | 31 | 35 | 36 | 43 | 44 | 53 | 54 | 55 | 59 | 60 | 66 | 67 | 74 | 75 | 85 | 86 | 87 | 88 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 110 | 111 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /posts/src/main/res/layout/activity_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | -------------------------------------------------------------------------------- /posts/src/main/res/layout/comment_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 19 | 20 | 27 | 28 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /posts/src/main/res/layout/post_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 19 | 20 | 27 | 28 | 36 | 37 | 42 | 43 | 50 | 51 | 61 | 62 | 63 | 64 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /posts/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /posts/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 64dp 4 | -------------------------------------------------------------------------------- /posts/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Posts 3 | Know More > 4 | Need internet to fetch latest posts! 5 | Failed to load posts. Please try again later. 6 | User\'s Avatar Image 7 | movie 8 | b 9 | No comments found for this post! 10 | 11 | -------------------------------------------------------------------------------- /posts/src/main/resources/api-response/comments.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "postId": 1, 4 | "id": 1, 5 | "name": "id labore ex et quam laborum", 6 | "email": "Eliseo@gardner.biz", 7 | "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium" 8 | }, 9 | { 10 | "postId": 1, 11 | "id": 2, 12 | "name": "quo vero reiciendis velit similique earum", 13 | "email": "Jayne_Kuhic@sydney.com", 14 | "body": "est natus enim nihil est dolore omnis voluptatem numquam\net omnis occaecati quod ullam at\nvoluptatem error expedita pariatur\nnihil sint nostrum voluptatem reiciendis et" 15 | }, 16 | { 17 | "postId": 1, 18 | "id": 3, 19 | "name": "odio adipisci rerum aut animi", 20 | "email": "Nikita@garfield.biz", 21 | "body": "quia molestiae reprehenderit quasi aspernatur\naut expedita occaecati aliquam eveniet laudantium\nomnis quibusdam delectus saepe quia accusamus maiores nam est\ncum et ducimus et vero voluptates excepturi deleniti ratione" 22 | }, 23 | { 24 | "postId": 1, 25 | "id": 4, 26 | "name": "alias odio sit", 27 | "email": "Lew@alysha.tv", 28 | "body": "non et atque\noccaecati deserunt quas accusantium unde odit nobis qui voluptatem\nquia voluptas consequuntur itaque dolor\net qui rerum deleniti ut occaecati" 29 | }, 30 | { 31 | "postId": 1, 32 | "id": 5, 33 | "name": "vero eaque aliquid doloribus et culpa", 34 | "email": "Hayden@althea.biz", 35 | "body": "harum non quasi et ratione\ntempore iure ex voluptates in ratione\nharum architecto fugit inventore cupiditate\nvoluptates magni quo et" 36 | } 37 | ] -------------------------------------------------------------------------------- /posts/src/main/resources/api-response/posts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "userId": 1, 4 | "id": 1, 5 | "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", 6 | "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" 7 | }, 8 | { 9 | "userId": 1, 10 | "id": 2, 11 | "title": "qui est esse", 12 | "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla" 13 | }, 14 | { 15 | "userId": 1, 16 | "id": 3, 17 | "title": "ea molestias quasi exercitationem repellat qui ipsa sit aut", 18 | "body": "et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut" 19 | }, 20 | { 21 | "userId": 1, 22 | "id": 4, 23 | "title": "eum et est occaecati", 24 | "body": "ullam et saepe reiciendis voluptatem adipisci\nsit amet autem assumenda provident rerum culpa\nquis hic commodi nesciunt rem tenetur doloremque ipsam iure\nquis sunt voluptatem rerum illo velit" 25 | }, 26 | { 27 | "userId": 1, 28 | "id": 5, 29 | "title": "nesciunt quas odio", 30 | "body": "repudiandae veniam quaerat sunt sed\nalias aut fugiat sit autem sed est\nvoluptatem omnis possimus esse voluptatibus quis\nest aut tenetur dolor neque" 31 | }, 32 | { 33 | "userId": 1, 34 | "id": 6, 35 | "title": "dolorem eum magni eos aperiam quia", 36 | "body": "ut aspernatur corporis harum nihil quis provident sequi\nmollitia nobis aliquid molestiae\nperspiciatis et ea nemo ab reprehenderit accusantium quas\nvoluptate dolores velit et doloremque molestiae" 37 | }, 38 | { 39 | "userId": 1, 40 | "id": 7, 41 | "title": "magnam facilis autem", 42 | "body": "dolore placeat quibusdam ea quo vitae\nmagni quis enim qui quis quo nemo aut saepe\nquidem repellat excepturi ut quia\nsunt ut sequi eos ea sed quas" 43 | }, 44 | { 45 | "userId": 1, 46 | "id": 8, 47 | "title": "dolorem dolore est ipsam", 48 | "body": "dignissimos aperiam dolorem qui eum\nfacilis quibusdam animi sint suscipit qui sint possimus cum\nquaerat magni maiores excepturi\nipsam ut commodi dolor voluptatum modi aut vitae" 49 | }, 50 | { 51 | "userId": 1, 52 | "id": 9, 53 | "title": "nesciunt iure omnis dolorem tempora et accusantium", 54 | "body": "consectetur animi nesciunt iure dolore\nenim quia ad\nveniam autem ut quam aut nobis\net est aut quod aut provident voluptas autem voluptas" 55 | }, 56 | { 57 | "userId": 1, 58 | "id": 10, 59 | "title": "optio molestias id quia eum", 60 | "body": "quo et expedita modi cum officia vel magni\ndoloribus qui repudiandae\nvero nisi sit\nquos veniam quod sed accusamus veritatis error" 61 | } 62 | ] -------------------------------------------------------------------------------- /posts/src/main/resources/api-response/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Leanne Graham", 5 | "username": "Bret", 6 | "email": "Sincere@april.biz", 7 | "address": { 8 | "street": "Kulas Light", 9 | "suite": "Apt. 556", 10 | "city": "Gwenborough", 11 | "zipcode": "92998-3874", 12 | "geo": { 13 | "lat": "-37.3159", 14 | "lng": "81.1496" 15 | } 16 | }, 17 | "phone": "1-770-736-8031 x56442", 18 | "website": "hildegard.org", 19 | "company": { 20 | "name": "Romaguera-Crona", 21 | "catchPhrase": "Multi-layered client-server neural-net", 22 | "bs": "harness real-time e-markets" 23 | } 24 | }, 25 | { 26 | "id": 2, 27 | "name": "Ervin Howell", 28 | "username": "Antonette", 29 | "email": "Shanna@melissa.tv", 30 | "address": { 31 | "street": "Victor Plains", 32 | "suite": "Suite 879", 33 | "city": "Wisokyburgh", 34 | "zipcode": "90566-7771", 35 | "geo": { 36 | "lat": "-43.9509", 37 | "lng": "-34.4618" 38 | } 39 | }, 40 | "phone": "010-692-6593 x09125", 41 | "website": "anastasia.net", 42 | "company": { 43 | "name": "Deckow-Crist", 44 | "catchPhrase": "Proactive didactic contingency", 45 | "bs": "synergize scalable supply-chains" 46 | } 47 | }, 48 | { 49 | "id": 3, 50 | "name": "Clementine Bauch", 51 | "username": "Samantha", 52 | "email": "Nathan@yesenia.net", 53 | "address": { 54 | "street": "Douglas Extension", 55 | "suite": "Suite 847", 56 | "city": "McKenziehaven", 57 | "zipcode": "59590-4157", 58 | "geo": { 59 | "lat": "-68.6102", 60 | "lng": "-47.0653" 61 | } 62 | }, 63 | "phone": "1-463-123-4447", 64 | "website": "ramiro.info", 65 | "company": { 66 | "name": "Romaguera-Jacobson", 67 | "catchPhrase": "Face to face bifurcated interface", 68 | "bs": "e-enable strategic applications" 69 | } 70 | }, 71 | { 72 | "id": 4, 73 | "name": "Patricia Lebsack", 74 | "username": "Karianne", 75 | "email": "Julianne.OConner@kory.org", 76 | "address": { 77 | "street": "Hoeger Mall", 78 | "suite": "Apt. 692", 79 | "city": "South Elvis", 80 | "zipcode": "53919-4257", 81 | "geo": { 82 | "lat": "29.4572", 83 | "lng": "-164.2990" 84 | } 85 | }, 86 | "phone": "493-170-9623 x156", 87 | "website": "kale.biz", 88 | "company": { 89 | "name": "Robel-Corkery", 90 | "catchPhrase": "Multi-tiered zero tolerance productivity", 91 | "bs": "transition cutting-edge web services" 92 | } 93 | }, 94 | { 95 | "id": 5, 96 | "name": "Chelsey Dietrich", 97 | "username": "Kamren", 98 | "email": "Lucio_Hettinger@annie.ca", 99 | "address": { 100 | "street": "Skiles Walks", 101 | "suite": "Suite 351", 102 | "city": "Roscoeview", 103 | "zipcode": "33263", 104 | "geo": { 105 | "lat": "-31.8129", 106 | "lng": "62.5342" 107 | } 108 | }, 109 | "phone": "(254)954-1289", 110 | "website": "demarco.info", 111 | "company": { 112 | "name": "Keebler LLC", 113 | "catchPhrase": "User-centric fault-tolerant solution", 114 | "bs": "revolutionize end-to-end systems" 115 | } 116 | }, 117 | { 118 | "id": 6, 119 | "name": "Mrs. Dennis Schulist", 120 | "username": "Leopoldo_Corkery", 121 | "email": "Karley_Dach@jasper.info", 122 | "address": { 123 | "street": "Norberto Crossing", 124 | "suite": "Apt. 950", 125 | "city": "South Christy", 126 | "zipcode": "23505-1337", 127 | "geo": { 128 | "lat": "-71.4197", 129 | "lng": "71.7478" 130 | } 131 | }, 132 | "phone": "1-477-935-8478 x6430", 133 | "website": "ola.org", 134 | "company": { 135 | "name": "Considine-Lockman", 136 | "catchPhrase": "Synchronised bottom-line interface", 137 | "bs": "e-enable innovative applications" 138 | } 139 | }, 140 | { 141 | "id": 7, 142 | "name": "Kurtis Weissnat", 143 | "username": "Elwyn.Skiles", 144 | "email": "Telly.Hoeger@billy.biz", 145 | "address": { 146 | "street": "Rex Trail", 147 | "suite": "Suite 280", 148 | "city": "Howemouth", 149 | "zipcode": "58804-1099", 150 | "geo": { 151 | "lat": "24.8918", 152 | "lng": "21.8984" 153 | } 154 | }, 155 | "phone": "210.067.6132", 156 | "website": "elvis.io", 157 | "company": { 158 | "name": "Johns Group", 159 | "catchPhrase": "Configurable multimedia task-force", 160 | "bs": "generate enterprise e-tailers" 161 | } 162 | }, 163 | { 164 | "id": 8, 165 | "name": "Nicholas Runolfsdottir V", 166 | "username": "Maxime_Nienow", 167 | "email": "Sherwood@rosamond.me", 168 | "address": { 169 | "street": "Ellsworth Summit", 170 | "suite": "Suite 729", 171 | "city": "Aliyaview", 172 | "zipcode": "45169", 173 | "geo": { 174 | "lat": "-14.3990", 175 | "lng": "-120.7677" 176 | } 177 | }, 178 | "phone": "586.493.6943 x140", 179 | "website": "jacynthe.com", 180 | "company": { 181 | "name": "Abernathy Group", 182 | "catchPhrase": "Implemented secondary concept", 183 | "bs": "e-enable extensible e-tailers" 184 | } 185 | }, 186 | { 187 | "id": 9, 188 | "name": "Glenna Reichert", 189 | "username": "Delphine", 190 | "email": "Chaim_McDermott@dana.io", 191 | "address": { 192 | "street": "Dayna Park", 193 | "suite": "Suite 449", 194 | "city": "Bartholomebury", 195 | "zipcode": "76495-3109", 196 | "geo": { 197 | "lat": "24.6463", 198 | "lng": "-168.8889" 199 | } 200 | }, 201 | "phone": "(775)976-6794 x41206", 202 | "website": "conrad.com", 203 | "company": { 204 | "name": "Yost and Sons", 205 | "catchPhrase": "Switchable contextually-based project", 206 | "bs": "aggregate real-time technologies" 207 | } 208 | }, 209 | { 210 | "id": 10, 211 | "name": "Clementina DuBuque", 212 | "username": "Moriah.Stanton", 213 | "email": "Rey.Padberg@karina.biz", 214 | "address": { 215 | "street": "Kattie Turnpike", 216 | "suite": "Suite 198", 217 | "city": "Lebsackbury", 218 | "zipcode": "31428-2261", 219 | "geo": { 220 | "lat": "-38.2386", 221 | "lng": "57.2232" 222 | } 223 | }, 224 | "phone": "024-648-3804", 225 | "website": "ambrose.net", 226 | "company": { 227 | "name": "Hoeger LLC", 228 | "catchPhrase": "Centralized empowering task-force", 229 | "bs": "target end-to-end models" 230 | } 231 | } 232 | ] -------------------------------------------------------------------------------- /posts/src/test/java/com/karntrehan/posts/commons/data/remote/PostServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.commons.data.remote 2 | 3 | import com.karntrehan.posts.core.testing.DependencyProvider 4 | import okhttp3.mockwebserver.MockResponse 5 | import okhttp3.mockwebserver.MockWebServer 6 | import org.junit.After 7 | import org.junit.Assert 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.robolectric.RobolectricTestRunner 12 | import java.io.IOException 13 | 14 | /** 15 | * Tests for [PostService] 16 | */ 17 | @RunWith(RobolectricTestRunner::class) 18 | class PostServiceTest { 19 | 20 | private lateinit var postService: PostService 21 | private lateinit var mockWebServer: MockWebServer 22 | 23 | @Before 24 | fun init() { 25 | mockWebServer = MockWebServer() 26 | postService = DependencyProvider 27 | .getRetrofit(mockWebServer.url("/")) 28 | .create(PostService::class.java) 29 | 30 | } 31 | 32 | @After 33 | @Throws(IOException::class) 34 | fun tearDown() { 35 | mockWebServer.shutdown() 36 | } 37 | 38 | @Test 39 | fun getUsers() { 40 | queueResponse { 41 | setResponseCode(200) 42 | setBody(DependencyProvider.getResponseFromJson("users")) 43 | } 44 | 45 | postService 46 | .getUsers() 47 | .test() 48 | .run { 49 | assertNoErrors() 50 | assertValueCount(1) 51 | Assert.assertEquals(values()[0].size, 10) 52 | Assert.assertEquals(values()[0][0].userName, "Leanne Graham") 53 | Assert.assertEquals(values()[0][0].id, 1) 54 | } 55 | } 56 | 57 | @Test 58 | fun getPosts() { 59 | queueResponse { 60 | setResponseCode(200) 61 | setBody(DependencyProvider.getResponseFromJson("posts")) 62 | } 63 | 64 | postService 65 | .getPosts() 66 | .test() 67 | .run { 68 | assertNoErrors() 69 | assertValueCount(1) 70 | Assert.assertEquals(values()[0].size, 10) 71 | Assert.assertEquals(values()[0][0].postTitle, "sunt aut facere repellat " + 72 | "provident occaecati excepturi optio reprehenderit") 73 | Assert.assertEquals(values()[0][0].userId, 1) 74 | } 75 | } 76 | 77 | @Test 78 | fun getComments() { 79 | queueResponse { 80 | setResponseCode(200) 81 | setBody(DependencyProvider.getResponseFromJson("comments")) 82 | } 83 | 84 | postService 85 | .getComments(1) 86 | .test() 87 | .run { 88 | assertNoErrors() 89 | assertValueCount(1) 90 | Assert.assertEquals(values()[0].size, 5) 91 | Assert.assertEquals(values()[0][0].id, 1) 92 | Assert.assertEquals(values()[0][0].email, "Eliseo@gardner.biz") 93 | } 94 | } 95 | 96 | 97 | private fun queueResponse(block: MockResponse.() -> Unit) { 98 | mockWebServer.enqueue(MockResponse().apply(block)) 99 | } 100 | } -------------------------------------------------------------------------------- /posts/src/test/java/com/karntrehan/posts/details/model/DetailsRemoteDataTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.model 2 | 3 | import com.karntrehan.posts.commons.data.remote.PostService 4 | import com.karntrehan.posts.commons.testing.DummyData 5 | import com.karntrehan.posts.list.model.ListRemoteData 6 | import com.nhaarman.mockito_kotlin.mock 7 | import com.nhaarman.mockito_kotlin.whenever 8 | import io.reactivex.Single 9 | import org.junit.Assert.assertEquals 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.robolectric.RobolectricTestRunner 13 | 14 | /** 15 | * Tests for [ListRemoteData] 16 | */ 17 | 18 | @RunWith(RobolectricTestRunner::class) 19 | class DetailsRemoteDataTest { 20 | private val postService = mock() 21 | 22 | @Test 23 | fun getCommentsForPost() { 24 | val userId = 1 25 | whenever(postService.getComments(userId)).thenReturn( 26 | Single.just( 27 | listOf( 28 | DummyData.Comment(userId, 101), 29 | DummyData.Comment(userId, 102), 30 | DummyData.Comment(userId, 103) 31 | ) 32 | ) 33 | ) 34 | 35 | DetailsRemoteData(postService).getCommentsForPost(userId).test().run { 36 | assertNoErrors() 37 | assertValueCount(1) 38 | assertEquals(values()[0].size, 3) 39 | assertEquals(values()[0][0].id, 101) 40 | assertEquals(values()[0][1].id, 102) 41 | assertEquals(values()[0][2].id, 103) 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /posts/src/test/java/com/karntrehan/posts/details/model/DetailsRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.model 2 | 3 | import com.karntrehan.posts.commons.testing.DummyData 4 | import com.karntrehan.posts.core.testing.TestScheduler 5 | import com.karntrehan.posts.commons.data.local.Comment 6 | import com.mpaani.core.networking.Outcome 7 | import com.nhaarman.mockito_kotlin.* 8 | import io.reactivex.Flowable 9 | import io.reactivex.Single 10 | import io.reactivex.disposables.CompositeDisposable 11 | import io.reactivex.observers.TestObserver 12 | import org.junit.Before 13 | import org.junit.Test 14 | import org.junit.runner.RunWith 15 | import org.robolectric.RobolectricTestRunner 16 | import java.io.IOException 17 | 18 | /** 19 | * Tests for [DetailsRepository] 20 | * */ 21 | @RunWith(RobolectricTestRunner::class) 22 | class DetailsRepositoryTest { 23 | private lateinit var repository: DetailsRepository 24 | private val local: DetailsDataContract.Local = mock() 25 | private val remote: DetailsDataContract.Remote = mock() 26 | private val compositeDisposable = CompositeDisposable() 27 | private val postId = 1 28 | 29 | @Before 30 | fun init() { 31 | repository = DetailsRepository(local, remote, TestScheduler(), compositeDisposable) 32 | whenever(local.getCommentsForPost(postId)).doReturn(Flowable.just(emptyList())) 33 | whenever(remote.getCommentsForPost(postId)).doReturn(Single.just(emptyList())) 34 | } 35 | 36 | /** 37 | * Verify if calling [DetailsRepository.fetchCommentsFor] triggers [DetailsDataContract.Local.getCommentsForPost] 38 | * and it's result is added to the [DetailsRepository.commentsFetchOutcome] 39 | * */ 40 | @Test 41 | fun testFetchCommentsFor() { 42 | val comments = listOf(DummyData.Comment(postId, 1), DummyData.Comment(postId, 2)) 43 | whenever(local.getCommentsForPost(postId)).doReturn(Flowable.just(comments)) 44 | 45 | val testObs = TestObserver>>() 46 | repository.commentsFetchOutcome.subscribe(testObs) 47 | 48 | testObs.assertEmpty() 49 | 50 | repository.fetchCommentsFor(postId) 51 | 52 | verify(local).getCommentsForPost(postId) 53 | 54 | testObs.assertValueAt(0, Outcome.loading(true)) 55 | testObs.assertValueAt(1, Outcome.loading(false)) 56 | testObs.assertValueAt(2, Outcome.success(comments)) 57 | } 58 | 59 | /** 60 | * Verify successful refresh of posts and users triggers [DetailsDataContract.Local.saveComments] 61 | * */ 62 | @Test 63 | fun testRefreshPostsTriggersSave() { 64 | val dummyComments = listOf(DummyData.Comment(postId, 1), DummyData.Comment(postId, 2)) 65 | whenever(remote.getCommentsForPost(postId)).doReturn(Single.just(dummyComments)) 66 | 67 | repository.refreshComments(postId) 68 | verify(local).saveComments(dummyComments) 69 | } 70 | 71 | /** 72 | * Verify erred refresh of posts and users pushes to [DetailsDataContract.Repository.commentsFetchOutcome] 73 | * with error 74 | * */ 75 | @Test 76 | fun testRefreshPostsFailurePushesToOutcome() { 77 | val exception = IOException() 78 | whenever(remote.getCommentsForPost(postId)).doReturn(Single.error(exception)) 79 | 80 | val obs = TestObserver>>() 81 | repository.commentsFetchOutcome.subscribe(obs) 82 | 83 | repository.refreshComments(postId) 84 | 85 | obs.assertValueAt(0, Outcome.loading(true)) 86 | obs.assertValueAt(1, Outcome.loading(false)) 87 | obs.assertValueAt(2, Outcome.failure(exception)) 88 | } 89 | } -------------------------------------------------------------------------------- /posts/src/test/java/com/karntrehan/posts/details/viewmodel/DetailsViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.details.viewmodel 2 | 3 | import androidx.lifecycle.Observer 4 | import com.karntrehan.posts.commons.testing.DummyData 5 | import com.karntrehan.posts.commons.data.local.Comment 6 | import com.karntrehan.posts.details.model.DetailsDataContract 7 | import com.mpaani.core.networking.Outcome 8 | import com.nhaarman.mockito_kotlin.doReturn 9 | import com.nhaarman.mockito_kotlin.mock 10 | import com.nhaarman.mockito_kotlin.verify 11 | import com.nhaarman.mockito_kotlin.whenever 12 | import io.reactivex.disposables.CompositeDisposable 13 | import io.reactivex.subjects.PublishSubject 14 | import org.junit.Before 15 | import org.junit.Test 16 | import org.junit.runner.RunWith 17 | import org.robolectric.RobolectricTestRunner 18 | import java.io.IOException 19 | 20 | 21 | /** 22 | * Tests for [DetailsViewModel] 23 | * */ 24 | @RunWith(RobolectricTestRunner::class) 25 | class DetailsViewModelTest { 26 | private lateinit var viewModel: DetailsViewModel 27 | private val repo: DetailsDataContract.Repository = mock() 28 | private val compositeDisposable = CompositeDisposable() 29 | private val outcome: Observer>> = mock() 30 | 31 | private val postId = 1 32 | 33 | @Before 34 | fun init() { 35 | viewModel = DetailsViewModel(repo, compositeDisposable) 36 | whenever(repo.commentsFetchOutcome).doReturn(PublishSubject.create()) 37 | viewModel.commentsOutcome.observeForever(outcome) 38 | } 39 | 40 | 41 | /** 42 | * Test [DetailsViewModel.loadCommentsFor] triggers [DetailsDataContract.Repository.fetchCommentsFor] & 43 | * livedata [DetailsViewModel.commentsOutcome] gets outcomes pushed 44 | * from [DetailsDataContract.Repository.commentsFetchOutcome] 45 | * */ 46 | @Test 47 | fun testLoadCommentsSuccess() { 48 | viewModel.loadCommentsFor(postId) 49 | verify(repo).fetchCommentsFor(postId) 50 | 51 | repo.commentsFetchOutcome.onNext(Outcome.loading(true)) 52 | verify(outcome).onChanged(Outcome.loading(true)) 53 | 54 | repo.commentsFetchOutcome.onNext(Outcome.loading(false)) 55 | verify(outcome).onChanged(Outcome.loading(false)) 56 | 57 | val data = listOf(DummyData.Comment(postId, 1), DummyData.Comment(postId, 2)) 58 | repo.commentsFetchOutcome.onNext(Outcome.success(data)) 59 | verify(outcome).onChanged(Outcome.success(data)) 60 | } 61 | 62 | /** 63 | * Test that [DetailsDataContract.Repository.commentsFetchOutcome] on exception passes exception to 64 | * live [DetailsViewModel.commentsOutcome] 65 | * */ 66 | @Test 67 | fun testLoadCommentsError() { 68 | val exception = IOException() 69 | repo.commentsFetchOutcome.onNext(Outcome.failure(exception)) 70 | verify(outcome).onChanged(Outcome.failure(exception)) 71 | } 72 | 73 | /** 74 | * Verify [DetailsViewModel.refreshCommentsFor] triggers [DetailsDataContract.Repository.refreshComments] 75 | **/ 76 | @Test 77 | fun testRefreshComments() { 78 | viewModel.refreshCommentsFor(postId) 79 | verify(repo).refreshComments(postId) 80 | } 81 | } -------------------------------------------------------------------------------- /posts/src/test/java/com/karntrehan/posts/list/model/ListRemoteDataTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.model 2 | 3 | import com.karntrehan.posts.commons.data.remote.PostService 4 | import com.karntrehan.posts.commons.testing.DummyData 5 | import com.nhaarman.mockito_kotlin.mock 6 | import com.nhaarman.mockito_kotlin.whenever 7 | import io.reactivex.Single 8 | import org.junit.Assert.assertEquals 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.robolectric.RobolectricTestRunner 12 | 13 | /** 14 | * Tests for [ListRemoteData] 15 | */ 16 | 17 | @RunWith(RobolectricTestRunner::class) 18 | class ListRemoteDataTest { 19 | 20 | private val postService = mock() 21 | 22 | @Test 23 | fun getPosts() { 24 | whenever(postService.getPosts()).thenReturn( 25 | Single.just( 26 | listOf( 27 | DummyData.Post(1, 101), 28 | DummyData.Post(2, 102) 29 | ) 30 | ) 31 | ) 32 | 33 | ListRemoteData(postService).getPosts().test().run { 34 | assertNoErrors() 35 | assertValueCount(1) 36 | assertEquals(values()[0].size, 2) 37 | assertEquals(values()[0][0].userId, 1) 38 | assertEquals(values()[0][0].postId, 101) 39 | assertEquals(values()[0][1].userId, 2) 40 | assertEquals(values()[0][1].postId, 102) 41 | 42 | } 43 | } 44 | 45 | @Test 46 | fun getUsers() { 47 | whenever(postService.getUsers()).thenReturn( 48 | Single.just( 49 | listOf( 50 | DummyData.User(201), 51 | DummyData.User(202) 52 | ) 53 | ) 54 | ) 55 | 56 | ListRemoteData(postService).getUsers().test().run { 57 | assertNoErrors() 58 | assertValueCount(1) 59 | assertEquals(values()[0].size, 2) 60 | assertEquals(values()[0][0].id, 201) 61 | assertEquals(values()[0][1].id, 202) 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /posts/src/test/java/com/karntrehan/posts/list/model/ListRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.model 2 | 3 | import com.karntrehan.posts.commons.testing.DummyData 4 | import com.karntrehan.posts.core.testing.TestScheduler 5 | import com.karntrehan.posts.commons.data.PostWithUser 6 | import com.mpaani.core.networking.Outcome 7 | import com.nhaarman.mockito_kotlin.* 8 | import io.reactivex.Flowable 9 | import io.reactivex.Single 10 | import io.reactivex.disposables.CompositeDisposable 11 | import io.reactivex.observers.TestObserver 12 | import org.junit.Before 13 | import org.junit.Test 14 | import org.junit.runner.RunWith 15 | import org.robolectric.RobolectricTestRunner 16 | import java.io.IOException 17 | 18 | /** 19 | * Tests for [ListRepository] 20 | * */ 21 | @RunWith(RobolectricTestRunner::class) 22 | class ListRepositoryTest { 23 | private val local: ListDataContract.Local = mock() 24 | private val remote: ListDataContract.Remote = mock() 25 | 26 | private lateinit var repository: ListRepository 27 | private val compositeDisposable = CompositeDisposable() 28 | 29 | @Before 30 | fun init() { 31 | repository = ListRepository(local, remote, TestScheduler(), compositeDisposable) 32 | whenever(local.getPostsWithUsers()).doReturn(Flowable.just(emptyList())) 33 | whenever(remote.getUsers()).doReturn(Single.just(emptyList())) 34 | whenever(remote.getPosts()).doReturn(Single.just(emptyList())) 35 | } 36 | 37 | /** 38 | * Verify if calling [ListRepository.fetchPosts] triggers [ListDataContract.Local.getPostsWithUsers] 39 | * and it's result is added to the [ListRepository.postFetchOutcome] 40 | * */ 41 | @Test 42 | fun testFetchPosts() { 43 | val postWithUsersSuccess = listOf(DummyData.PostWithUser(1), DummyData.PostWithUser(2)) 44 | whenever(local.getPostsWithUsers()).doReturn(Flowable.just(postWithUsersSuccess)) 45 | 46 | val obs = TestObserver>>() 47 | 48 | repository.postFetchOutcome.subscribe(obs) 49 | obs.assertEmpty() 50 | 51 | repository.fetchPosts() 52 | verify(local).getPostsWithUsers() 53 | 54 | obs.assertValueAt(0, Outcome.loading(true)) 55 | obs.assertValueAt(1, Outcome.loading(false)) 56 | obs.assertValueAt(2, Outcome.success(postWithUsersSuccess)) 57 | } 58 | 59 | /** 60 | * Verify successful refresh of posts and users triggers [ListDataContract.Local.saveUsersAndPosts] 61 | * */ 62 | @Test 63 | fun testRefreshPostsTriggersSave() { 64 | val userId = 1 65 | val dummyUsers = listOf(DummyData.User(userId)) 66 | val dummyPosts = listOf(DummyData.Post(userId, 1)) 67 | whenever(remote.getUsers()).doReturn(Single.just(dummyUsers)) 68 | whenever(remote.getPosts()).doReturn(Single.just(dummyPosts)) 69 | 70 | repository.refreshPosts() 71 | verify(local).saveUsersAndPosts(dummyUsers, dummyPosts) 72 | } 73 | 74 | /** 75 | * Verify erred refresh of posts and users pushes to [ListDataContract.Repository.postFetchOutcome] 76 | * with error 77 | * */ 78 | @Test 79 | fun testRefreshPostsFailurePushesToOutcome() { 80 | val exception = IOException() 81 | whenever(remote.getUsers()).doReturn(Single.error(exception)) 82 | 83 | val obs = TestObserver>>() 84 | repository.postFetchOutcome.subscribe(obs) 85 | 86 | repository.refreshPosts() 87 | 88 | obs.assertValueAt(0, Outcome.loading(true)) 89 | obs.assertValueAt(1, Outcome.loading(false)) 90 | obs.assertValueAt(2, Outcome.failure(exception)) 91 | } 92 | } -------------------------------------------------------------------------------- /posts/src/test/java/com/karntrehan/posts/list/viewmodel/ListViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.karntrehan.posts.list.viewmodel 2 | 3 | import androidx.lifecycle.Observer 4 | import com.karntrehan.posts.commons.testing.DummyData 5 | import com.karntrehan.posts.commons.data.PostWithUser 6 | import com.karntrehan.posts.list.model.ListDataContract 7 | import com.mpaani.core.networking.Outcome 8 | import com.nhaarman.mockito_kotlin.doReturn 9 | import com.nhaarman.mockito_kotlin.mock 10 | import com.nhaarman.mockito_kotlin.verify 11 | import com.nhaarman.mockito_kotlin.whenever 12 | import io.reactivex.disposables.CompositeDisposable 13 | import io.reactivex.subjects.PublishSubject 14 | import org.junit.Before 15 | import org.junit.Test 16 | import org.junit.runner.RunWith 17 | import org.robolectric.RobolectricTestRunner 18 | import java.io.IOException 19 | 20 | /** 21 | * Tests for [ListViewModel] 22 | * */ 23 | @RunWith(RobolectricTestRunner::class) 24 | class ListViewModelTest { 25 | 26 | private lateinit var viewModel: ListViewModel 27 | 28 | private val repo: ListDataContract.Repository = mock() 29 | 30 | private val outcome: Observer>> = mock() 31 | 32 | @Before 33 | fun init() { 34 | viewModel = ListViewModel(repo, CompositeDisposable()) 35 | whenever(repo.postFetchOutcome).doReturn(PublishSubject.create()) 36 | viewModel.postsOutcome.observeForever(outcome) 37 | } 38 | 39 | /** 40 | * Test [ListViewModel.getPosts] triggers [ListDataContract.Repository.fetchPosts] & 41 | * livedata [ListViewModel.postsOutcome] gets outcomes pushed 42 | * from [ListDataContract.Repository.postFetchOutcome] 43 | * */ 44 | @Test 45 | fun testGetPostsSuccess() { 46 | viewModel.getPosts() 47 | verify(repo).fetchPosts() 48 | 49 | repo.postFetchOutcome.onNext(Outcome.loading(true)) 50 | verify(outcome).onChanged(Outcome.loading(true)) 51 | 52 | repo.postFetchOutcome.onNext(Outcome.loading(false)) 53 | verify(outcome).onChanged(Outcome.loading(false)) 54 | 55 | val data = listOf(DummyData.PostWithUser(1), DummyData.PostWithUser(2)) 56 | repo.postFetchOutcome.onNext(Outcome.success(data)) 57 | verify(outcome).onChanged(Outcome.success(data)) 58 | } 59 | 60 | /** 61 | * Test that [ListDataContract.Repository.postFetchOutcome] on exception passes exception to 62 | * live [ListViewModel.postsOutcome] 63 | * */ 64 | @Test 65 | fun testGetPostsError() { 66 | val exception = IOException() 67 | repo.postFetchOutcome.onNext(Outcome.failure(exception)) 68 | verify(outcome).onChanged(Outcome.failure(exception)) 69 | } 70 | 71 | /** 72 | * Verify [ListViewModel.refreshPosts] triggers [ListDataContract.Repository.refreshPosts] 73 | * */ 74 | @Test 75 | fun testRefreshPosts() { 76 | viewModel.refreshPosts() 77 | verify(repo).refreshPosts() 78 | } 79 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':core', ':posts' 2 | --------------------------------------------------------------------------------