├── .github └── workflows │ └── build-workflow.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro ├── schemas │ └── com.akshay.newsapp.news.storage.NewsDatabase │ │ └── 1.json └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── akshay │ │ └── newsapp │ │ ├── core │ │ └── utils │ │ │ └── DaoTest.kt │ │ └── news │ │ └── storage │ │ └── NewsArticlesDaoTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── akshay │ │ │ └── newsapp │ │ │ ├── NewsApp.kt │ │ │ ├── core │ │ │ ├── di │ │ │ │ └── NetworkModule.kt │ │ │ ├── mapper │ │ │ │ └── Mapper.kt │ │ │ ├── ui │ │ │ │ ├── ViewState.kt │ │ │ │ └── base │ │ │ │ │ └── DaggerActivity.kt │ │ │ ├── utils │ │ │ │ ├── LiveDataExtensions.kt │ │ │ │ ├── RetrofitExtensions.kt │ │ │ │ └── ViewExtensions.kt │ │ │ └── widget │ │ │ │ └── CompleteRecyclerView.kt │ │ │ └── news │ │ │ ├── NewsMapper.kt │ │ │ ├── api │ │ │ ├── NewsArticle.kt │ │ │ ├── NewsResponse.kt │ │ │ └── NewsService.kt │ │ │ ├── di │ │ │ ├── NewsDatabaseModule.kt │ │ │ └── NewsServiceModule.kt │ │ │ ├── domain │ │ │ └── NewsRepository.kt │ │ │ ├── storage │ │ │ ├── NewsArticlesDao.kt │ │ │ ├── NewsDatabase.kt │ │ │ ├── NewsDatabaseMigrations.kt │ │ │ └── entity │ │ │ │ └── NewsArticleDb.kt │ │ │ └── ui │ │ │ ├── activity │ │ │ └── NewsActivity.kt │ │ │ ├── adapter │ │ │ └── NewsArticlesAdapter.kt │ │ │ ├── model │ │ │ └── NewsAdapterEvent.kt │ │ │ └── viewmodel │ │ │ └── NewsArticleViewModel.kt │ └── res │ │ ├── drawable-nodpi │ │ └── tools_placeholder.jpg │ │ ├── drawable-v21 │ │ └── drawable_list_item.xml │ │ ├── drawable │ │ ├── drawable_list_item.xml │ │ └── ic_newspaper.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── empty_layout.xml │ │ ├── progress_layout.xml │ │ └── row_news_article.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ └── styles_news.xml │ ├── sharedTest │ └── kotlin │ │ └── com │ │ └── akshay │ │ └── newsapp │ │ └── core │ │ └── utils │ │ ├── FlowTestUtils.kt │ │ └── MockitoTest.kt │ └── test │ ├── java │ └── com │ │ └── akshay │ │ └── newsapp │ │ └── news │ │ ├── api │ │ ├── BaseServiceTest.kt │ │ └── NewsSourceServiceTest.kt │ │ └── domain │ │ └── NewsRepositoryTest.kt │ └── resources │ └── api-response │ └── news_source.json ├── art ├── dependency-graph.png └── screen.png ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.github/workflows/build-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | # 1. Checkout the repository 17 | - name: Checkout repository 18 | uses: actions/checkout@v2 19 | 20 | # 2. Setup JDK 21 | - name: Setup JDK 1.8 22 | uses: actions/setup-java@v1 23 | with: 24 | java-version: 1.8 25 | 26 | # 3. Setup Gradle cache 27 | - name: Cache Gradle Caches 28 | uses: actions/cache@v1 29 | with: 30 | path: ~/.gradle/caches/ 31 | key: cache-clean-gradle-${{ matrix.os }}-${{ matrix.jdk }} 32 | - name: Cache Gradle Wrapper 33 | uses: actions/cache@v1 34 | with: 35 | path: ~/.gradle/wrapper/ 36 | key: cache-clean-wrapper-${{ matrix.os }}-${{ matrix.jdk }} 37 | 38 | # 4: Decode credentials.properties file 39 | - name: Decode credentials.properties 40 | env: 41 | CREDENTIALS_PROPERTIES: ${{ secrets.CREDENTIALS_PROPERTIES }} 42 | run: echo $CREDENTIALS_PROPERTIES > credentials.properties 43 | 44 | # 5. Build 45 | - name: Build with Gradle 46 | run: ./gradlew build 47 | 48 | # 6. Test 49 | - name: Unit tests 50 | run: ./gradlew test 51 | 52 | # 7. Upload APK Artifact 53 | - name: Upload APK 54 | uses: actions/upload-artifact@v2 55 | with: 56 | name: news-app 57 | path: ./**/*.apk 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | credentials.properties -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at akshaychordiya2@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Please ensure your pull request adheres to the following guidelines: 4 | 5 | - Search previous suggestions before making a new one, as yours may be a duplicate. 6 | - Make an individual pull request for each suggestion. 7 | - Check your spelling and grammar. 8 | - Make sure your text editor is set to remove trailing whitespace. 9 | - The pull request and commit should have a useful title. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Akshay Chordiya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # News App 🗞 2 | [![GitHub license](https://img.shields.io/github/license/mashape/apistatus.svg)](https://github.com/AkshayChordiya//blob/master/LICENSE) 3 | 4 | News App is a simple news app 🗞️ which uses [NewsAPI](https://newsapi.org/) to fetch top news headlines from the API. The main aim of this app is to be a leading example of how to build Modern Android applications for all Android Developers 5 | 6 | The codebase focuses 👓 on following key things: 7 | - Code structuring, style and comments 8 | - Dependency injection 🗡 9 | - Offline first ✈️ 10 | - Kotlin + Coroutines 11 | - And tests 🛠 12 | - Emojis (ofcourse) 😛 13 | 14 | The idea is to keep the app super simple while demonstrating new libraries and tools which makes it easier to build high quality Android applications. 15 | 16 | 17 | NewsApp Main Page 18 | 19 | # Development Setup 🖥 20 | 21 | You will require latest version of Android Studio 3.0 (or newer) to be able to build the app 22 | 23 | ## API key 🔑 24 | You'll need to provide API key to fetch the news from the News Service (API). Currently the news is fetched from [NewsAPI](https://newsapi.org/) 25 | 26 | - Generate an API key (It's only 2 steps!) from [NewsAPI](https://newsapi.org/) 27 | - Create new file named -> `credentials.properties` in our project root folder 28 | - Add the API key as shown below [Make sure to keep the double quotes]: 29 | ``` 30 | NEWS_API_KEY = "" 31 | ``` 32 | - Build the app 33 | - Enjoyyyyy 🎉 34 | 35 | 36 | ## Libraries and tools 🛠 37 | 38 | News App uses libraries and tools used to build Modern Android application, mainly part of Android Jetpack 🚀 39 | 40 | - [Kotlin](https://kotlinlang.org/) first 41 | - [Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) and [Flow](https://kotlinlang.org/docs/reference/coroutines/flow.html) first 42 | - Architecture components 43 | - [Dagger 2](https://developer.android.com/training/dependency-injection) for dependency injection 🗡 44 | - [Retrofit](https://square.github.io/retrofit/) 45 | - Other [Android Jetpack](https://developer.android.com/jetpack) components 46 | 47 | 48 | ## Architecture 49 | 50 | The app uses MVVM [Model-View-ViewModel] architecture to have a unidirectional flow of data, separation of concern, testability, and a lot more. 51 | 52 | Read more: 53 | - [Building Modern Android Apps with Architecture Guidelines](https://medium.com/@aky/building-modern-apps-using-the-android-architecture-guidelines-3238fff96f14) 54 | - [Guide to app architecture](https://developer.android.com/jetpack/docs/guide) 55 | 56 | ![Architecture](https://developer.android.com/topic/libraries/architecture/images/final-architecture.png) 57 | 58 | ## Dependency Graph 🔪 59 | 60 | The following diagram shows the dependency graph of the app. 61 | 62 | News App Dependency Graph 63 | 64 | Generated by [Daggraph](https://github.com/dvdciri/daggraph) 65 | 66 | -------------------- 67 | 68 | ## Learn Architecture Components 69 | Trying to learn the new Architecture Components. I have wrote a series of articles to understand Android Architecture Components. Feel free to check it out to learn more. 70 | 71 | - [Introduction to Architecture Components](https://medium.com/@aky/introduction-to-android-architecture-components-22b8c84f0b9d) 72 | - [Exploring ViewModel Architecture component](https://medium.com/@aky/exploring-viewmodel-architecture-component-5d60828172f9) 73 | - [Exploring LiveData Architecture component](https://medium.com/@aky/exploring-livedata-architecture-component-f9375d3644ee) 74 | - [Exploring Room Architecture component](https://medium.com/@aky/exploring-room-architecture-component-6db807094241) 75 | - [Building Modern Android Apps with Architecture Guidelines](https://medium.com/@aky/building-modern-apps-using-the-android-architecture-guidelines-3238fff96f14) 76 | 77 | ### Extra - Caster.io Course and Podcast 78 | - [Android Architecture Components - A Deep Dive - Caster.io](https://caster.io/courses/android-architecture-components-deep-dive) 📺 79 | - [Android Architecture Components Podcast on Fragmented](http://fragmentedpodcast.com/episodes/115/) 🎤 80 | 81 | 82 | ## Testing 83 | The architecture components are highly testable. Following table shows how to test various parts of the app (cheatsheet for testing architecture component) 84 | 85 | | Component | Test | Mock | 86 | |:----------:|:------------:|:------------------:| 87 | | UI | Espresso | ViewModel | 88 | | ViewModel | JUnit | Repository | 89 | | Repository | JUnit | DAO and WebService | 90 | | DAO | Instrumented | - | 91 | | WebService | Instrumented | MockWebServer | 92 | 93 | 94 | ## Contributing 95 | 96 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for contributions. 97 | 98 | ## License 99 | 100 | The MIT License (MIT) 101 | 102 | Copyright (c) 2017 Akshay Chordiya 103 | 104 | Permission is hereby granted, free of charge, to any person obtaining a copy 105 | of this software and associated documentation files (the "Software"), to deal 106 | in the Software without restriction, including without limitation the rights 107 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 108 | copies of the Software, and to permit persons to whom the Software is 109 | furnished to do so, subject to the following conditions: 110 | 111 | The above copyright notice and this permission notice shall be included in all 112 | copies or substantial portions of the Software. 113 | 114 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 115 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 116 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 117 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 118 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 119 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 120 | SOFTWARE. 121 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: 'dagger.hilt.android.plugin' 6 | 7 | // Read credentials 8 | def credentialFile = rootProject.file("credentials.properties") 9 | def credentialProperty = new Properties() 10 | credentialProperty.load(new FileInputStream(credentialFile)) 11 | 12 | android { 13 | compileSdkVersion androidCompileSdkVersion 14 | defaultConfig { 15 | applicationId "com.akshay.newsapp" 16 | minSdkVersion androidMinSdkVersion 17 | versionCode 4 18 | versionName "3.0.0" 19 | vectorDrawables.useSupportLibrary = true 20 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 21 | buildConfigField("String", "NEWS_API_KEY", credentialProperty['NEWS_API_KEY']) 22 | javaCompileOptions { 23 | annotationProcessorOptions { 24 | arguments += [ 25 | "room.schemaLocation": "$projectDir/schemas".toString(), 26 | "room.incremental": "true" 27 | ] 28 | } 29 | } 30 | } 31 | buildTypes { 32 | release { 33 | minifyEnabled false 34 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 35 | } 36 | } 37 | compileOptions { 38 | sourceCompatibility JavaVersion.VERSION_1_8 39 | targetCompatibility JavaVersion.VERSION_1_8 40 | } 41 | 42 | sourceSets { 43 | // Share test utilities between unit and instrumented tests 44 | test.java.srcDirs += 'src/sharedTest/kotlin' 45 | androidTest.java.srcDirs += 'src/sharedTest/kotlin' 46 | } 47 | 48 | packagingOptions { 49 | // Exclude files not needed at runtime 50 | exclude "META-INF/**" 51 | exclude "*.txt" 52 | 53 | // Exclude Kotlin metadata 54 | exclude "**.kotlin_metadata" 55 | exclude "**.kotlin_builtins" 56 | exclude "**.kotlin_module" 57 | } 58 | 59 | buildFeatures { 60 | viewBinding true 61 | } 62 | } 63 | 64 | kapt { 65 | mapDiagnosticLocations = true 66 | // arguments { 67 | // arg("room.schemaLocation", roomSchemaDir) 68 | // } 69 | } 70 | 71 | dependencies { 72 | implementation fileTree(dir: 'libs', include: ['*.jar']) 73 | 74 | // Kotlin 75 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" 76 | 77 | // Kotlin Flow 78 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" 79 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" 80 | testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" 81 | 82 | // Support Libraries 83 | implementation "androidx.appcompat:appcompat:$supportVersion" 84 | implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" 85 | implementation "androidx.cardview:cardview:$cardViewVersion" 86 | implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" 87 | 88 | // Retrofit 89 | implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" 90 | implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" 91 | implementation "com.squareup.okhttp3:logging-interceptor:$okHttpVersion" 92 | implementation "com.github.mrmike:ok2curl:$curlVersion" 93 | testImplementation "com.squareup.okhttp3:mockwebserver:$okHttpVersion" 94 | 95 | // Lifecycle (ViewModel + LiveData) 96 | implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" 97 | implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" 98 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:$liveDataKtx" 99 | testImplementation "androidx.arch.core:core-testing:$archTestingVersion" 100 | 101 | // Room 102 | implementation "androidx.room:room-runtime:$roomVersion" 103 | kapt "androidx.room:room-compiler:$roomVersion" 104 | implementation "androidx.room:room-ktx:$roomVersion" 105 | androidTestImplementation "androidx.room:room-testing:$roomVersion" 106 | 107 | // Coil 108 | implementation "io.coil-kt:coil:$coilVersion" 109 | 110 | // Hilt + Dagger 111 | implementation "com.google.dagger:hilt-android:$hiltAndroidVersion" 112 | kapt "com.google.dagger:hilt-android-compiler:$hiltAndroidVersion" 113 | implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hiltViewModelVersion" 114 | kapt "androidx.hilt:hilt-compiler:$hiltViewModelVersion" 115 | 116 | // KTX 117 | implementation "androidx.core:core-ktx:$coreKtxVersion" 118 | implementation "androidx.fragment:fragment-ktx:$fragmentKtxVersion" 119 | 120 | // Testing 121 | testImplementation "junit:junit:$jUnitVersion" 122 | androidTestImplementation "androidx.test.ext:junit:$androidjUnitVersion" 123 | androidTestImplementation("androidx.test.espresso:espresso-core:$espressoVersion", { 124 | exclude group: 'com.android.support', module: 'support-annotations' 125 | }) 126 | testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion" 127 | androidTestImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion" 128 | 129 | // Debug 130 | implementation "com.jakewharton.timber:timber:$timberVersion" 131 | } 132 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in D:\AndroidSDK/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | 19 | # Uncomment this to preserve the line number information for 20 | # debugging stack traces. 21 | #-keepattributes SourceFile,LineNumberTable 22 | 23 | # If you keep the line number information, uncomment this to 24 | # hide the original source file name. 25 | #-renamesourcefileattribute SourceFile 26 | -------------------------------------------------------------------------------- /app/schemas/com.akshay.newsapp.news.storage.NewsDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "cd4637a6bf348ca0ced8a0460381170d", 6 | "entities": [ 7 | { 8 | "tableName": "news_article", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `author` TEXT, `title` TEXT, `description` TEXT, `url` TEXT, `urlToImage` TEXT, `publishedAt` TEXT)", 10 | "fields": [ 11 | { 12 | "fieldPath": "id", 13 | "columnName": "id", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "author", 19 | "columnName": "author", 20 | "affinity": "TEXT", 21 | "notNull": false 22 | }, 23 | { 24 | "fieldPath": "title", 25 | "columnName": "title", 26 | "affinity": "TEXT", 27 | "notNull": false 28 | }, 29 | { 30 | "fieldPath": "description", 31 | "columnName": "description", 32 | "affinity": "TEXT", 33 | "notNull": false 34 | }, 35 | { 36 | "fieldPath": "url", 37 | "columnName": "url", 38 | "affinity": "TEXT", 39 | "notNull": false 40 | }, 41 | { 42 | "fieldPath": "urlToImage", 43 | "columnName": "urlToImage", 44 | "affinity": "TEXT", 45 | "notNull": false 46 | }, 47 | { 48 | "fieldPath": "publishedAt", 49 | "columnName": "publishedAt", 50 | "affinity": "TEXT", 51 | "notNull": false 52 | } 53 | ], 54 | "primaryKey": { 55 | "columnNames": [ 56 | "id" 57 | ], 58 | "autoGenerate": true 59 | }, 60 | "indices": [], 61 | "foreignKeys": [] 62 | } 63 | ], 64 | "views": [], 65 | "setupQueries": [ 66 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 67 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd4637a6bf348ca0ced8a0460381170d')" 68 | ] 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/akshay/newsapp/core/utils/DaoTest.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.utils 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import androidx.room.RoomDatabase 6 | import androidx.test.core.app.ApplicationProvider 7 | import org.junit.After 8 | import org.junit.Before 9 | 10 | /** 11 | * Base class to reduce boilerplate code for testing DAO(s) of [RoomDatabase]. 12 | */ 13 | abstract class DaoTest( 14 | private val database: Class 15 | ): MockitoTest() { 16 | 17 | protected lateinit var db: Database 18 | 19 | @Before 20 | override fun setup() { 21 | super.setup() 22 | db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), database).build() 23 | } 24 | 25 | @After 26 | fun teardown() = db.close() 27 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/akshay/newsapp/news/storage/NewsArticlesDaoTest.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.storage 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import com.akshay.newsapp.core.utils.DaoTest 5 | import com.akshay.newsapp.core.utils.assertItems 6 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb 7 | import kotlinx.coroutines.runBlocking 8 | import org.hamcrest.CoreMatchers.equalTo 9 | import org.hamcrest.MatcherAssert.assertThat 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | 13 | @RunWith(AndroidJUnit4::class) 14 | class NewsArticlesDaoTest : DaoTest(NewsDatabase::class.java) { 15 | 16 | @Test 17 | @Throws(InterruptedException::class) 18 | fun insertNewsArticles() { 19 | // GIVEN 20 | val input = listOf(NewsArticleDb(1), NewsArticleDb(2)) 21 | 22 | // THEN 23 | assertThat(db.newsArticlesDao().insertArticles(input), equalTo(listOf(1L, 2L))) 24 | } 25 | 26 | @Test 27 | @Throws(InterruptedException::class) 28 | fun insertNewsArticlesAndRead(): Unit = runBlocking { 29 | // GIVEN 30 | val input = listOf( 31 | NewsArticleDb(1, "First", "Hello"), 32 | NewsArticleDb(2, "Second", "Testing") 33 | ) 34 | db.newsArticlesDao().insertArticles(input) 35 | 36 | // THEN 37 | db.newsArticlesDao().getNewsArticles().assertItems(input) 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/NewsApp.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | import timber.log.Timber 6 | import timber.log.Timber.DebugTree 7 | 8 | @HiltAndroidApp 9 | class NewsApp : Application() { 10 | 11 | override fun onCreate() { 12 | super.onCreate() 13 | 14 | // Plant timber 15 | if (BuildConfig.DEBUG) { 16 | Timber.plant(DebugTree()) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/core/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.di 2 | 3 | import com.moczul.ok2curl.CurlInterceptor 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.android.components.ApplicationComponent 8 | import okhttp3.OkHttpClient 9 | import retrofit2.Retrofit 10 | import retrofit2.converter.gson.GsonConverterFactory 11 | import timber.log.Timber 12 | import javax.inject.Singleton 13 | 14 | 15 | @Module 16 | @InstallIn(ApplicationComponent::class) 17 | object NetworkModule { 18 | 19 | private const val BASE_URL = "https://newsapi.org/v2/" 20 | 21 | @Singleton 22 | @Provides 23 | fun provideOkHttpClient(): OkHttpClient { 24 | return OkHttpClient.Builder() 25 | .addInterceptor(CurlInterceptor(Timber::d)) 26 | .build() 27 | } 28 | 29 | @Singleton 30 | @Provides 31 | fun provideRetrofit(client: OkHttpClient): Retrofit { 32 | return Retrofit.Builder() 33 | .baseUrl(BASE_URL) 34 | .client(client) 35 | .addConverterFactory(GsonConverterFactory.create()) 36 | .build() 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/core/mapper/Mapper.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.mapper 2 | 3 | /** 4 | * Interface for model mappers. It provides helper methods that facilitate 5 | * retrieving of models from outer data source layers 6 | * 7 | * @param the cached model input type 8 | * @param the remote model input type 9 | */ 10 | interface Mapper { 11 | fun Storage.toRemote(): Remote 12 | fun Remote.toStorage(): Storage 13 | fun List.toRemote(): List = this.map { it.toRemote() } 14 | fun List.toStorage(): List = this.map { it.toStorage() } 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/core/ui/ViewState.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.ui 2 | 3 | /** 4 | * Describes state of the view at any 5 | * point of time. 6 | */ 7 | sealed class ViewState { 8 | 9 | /** 10 | * Describes success state of the UI with 11 | * [data] shown 12 | */ 13 | data class Success( 14 | val data: ResultType 15 | ) : ViewState() 16 | 17 | /** 18 | * Describes loading state of the UI 19 | */ 20 | class Loading : ViewState() { 21 | override fun equals(other: Any?): Boolean { 22 | if (this === other) return true 23 | if (javaClass != other?.javaClass) return false 24 | return true 25 | } 26 | 27 | override fun hashCode(): Int = javaClass.hashCode() 28 | } 29 | 30 | /** 31 | * Describes error state of the UI 32 | */ 33 | data class Error( 34 | val message: String 35 | ) : ViewState() 36 | 37 | companion object { 38 | /** 39 | * Creates [ViewState] object with [Success] state and [data]. 40 | */ 41 | fun success(data: ResultType): ViewState = Success(data) 42 | 43 | /** 44 | * Creates [ViewState] object with [Loading] state to notify 45 | * the UI to showing loading. 46 | */ 47 | fun loading(): ViewState = Loading() 48 | 49 | /** 50 | * Creates [ViewState] object with [Error] state and [message]. 51 | */ 52 | fun error(message: String): ViewState = Error(message) 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/core/ui/base/DaggerActivity.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.ui.base 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import androidx.lifecycle.ViewModel 5 | import dagger.hilt.android.AndroidEntryPoint 6 | 7 | // Easy to switch base activity in future 8 | typealias BaseActivity = DaggerActivity 9 | 10 | /** 11 | * Base activity providing Dagger support and [ViewModel] support 12 | */ 13 | @AndroidEntryPoint 14 | abstract class DaggerActivity : AppCompatActivity() 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/core/utils/LiveDataExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.utils 2 | 3 | import androidx.lifecycle.LifecycleOwner 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.Observer 6 | 7 | /** 8 | * Syntactic sugar for [LiveData.observeNotNull] function where the [Observer] is the last parameter. 9 | * Hence can be passed outside the function parenthesis. 10 | */ 11 | inline fun LiveData.observeNotNull(owner: LifecycleOwner, crossinline observer: (T) -> Unit) { 12 | this.observe(owner, Observer { it?.apply(observer) }) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/core/utils/RetrofitExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.utils 2 | 3 | import okhttp3.ResponseBody.Companion.toResponseBody 4 | import retrofit2.Response 5 | import retrofit2.Retrofit 6 | 7 | // Retrofit 8 | 9 | /** 10 | * Synthetic sugaring to create Retrofit Service. 11 | */ 12 | inline fun Retrofit.create(): T = create(T::class.java) 13 | 14 | /** 15 | * Creates a fake error response. 16 | */ 17 | fun httpError(code: Int): Response = Response.error(code, "".toResponseBody(null)) -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/core/utils/ViewExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.utils 2 | 3 | import android.content.Context 4 | import android.graphics.PorterDuff 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import android.widget.Toast 9 | import androidx.annotation.ColorRes 10 | import androidx.annotation.DrawableRes 11 | import androidx.appcompat.content.res.AppCompatResources 12 | import androidx.core.content.ContextCompat 13 | import androidx.fragment.app.Fragment 14 | import androidx.fragment.app.FragmentActivity 15 | 16 | fun View.visible() { 17 | visibility = View.VISIBLE 18 | } 19 | 20 | fun View.invisible() { 21 | visibility = View.INVISIBLE 22 | } 23 | 24 | fun View.gone() { 25 | visibility = View.GONE 26 | } 27 | 28 | fun Context.getColorCompat(@ColorRes colorRes: Int) = ContextCompat.getColor(this, colorRes) 29 | fun Fragment.getColor(@ColorRes colorRes: Int) = ContextCompat.getColor(requireContext(), colorRes) 30 | 31 | /** 32 | * Easy toast function for Activity. 33 | */ 34 | fun FragmentActivity.toast(text: String, duration: Int = Toast.LENGTH_SHORT) { 35 | Toast.makeText(this, text, duration).show() 36 | } 37 | 38 | /** 39 | * Inflate the layout specified by [layoutRes]. 40 | */ 41 | fun ViewGroup.inflate(layoutRes: Int): View { 42 | return LayoutInflater.from(context).inflate(layoutRes, this, false) 43 | } 44 | 45 | fun Context.getDrawableCompat(@DrawableRes resId: Int, @ColorRes tintColorRes: Int = 0) = when { 46 | tintColorRes != 0 -> AppCompatResources.getDrawable(this, resId)?.apply { 47 | setColorFilter(getColorCompat(tintColorRes), PorterDuff.Mode.SRC_ATOP) 48 | } 49 | else -> AppCompatResources.getDrawable(this, resId) 50 | }!! -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/core/widget/CompleteRecyclerView.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.widget 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import android.view.View 6 | import android.widget.ImageView 7 | import android.widget.TextView 8 | import androidx.annotation.DrawableRes 9 | import androidx.annotation.StringRes 10 | import androidx.recyclerview.widget.GridLayoutManager 11 | import androidx.recyclerview.widget.RecyclerView 12 | import com.akshay.newsapp.R 13 | import com.akshay.newsapp.core.utils.gone 14 | import com.akshay.newsapp.core.utils.visible 15 | import kotlin.math.max 16 | 17 | /** 18 | * A custom implementation of [RecyclerView] to support 19 | * Empty View & Loading animation. 20 | */ 21 | class CompleteRecyclerView @JvmOverloads constructor( 22 | context: Context, 23 | attrs: AttributeSet? = null, 24 | defStyle: Int = 0 25 | ) : RecyclerView(context, attrs, defStyle) { 26 | 27 | /** 28 | * Empty layout 29 | */ 30 | private var emptyView: View? = null 31 | 32 | /** 33 | * Progress view 34 | */ 35 | private var progressView: View? = null 36 | 37 | /** 38 | * Column width for grid layout 39 | */ 40 | private var columnWidth: Int = 0 41 | 42 | init { 43 | gone() 44 | if (attrs != null) { 45 | val attrsArray = intArrayOf(android.R.attr.columnWidth) 46 | val array = context.obtainStyledAttributes( 47 | attrs, attrsArray) 48 | columnWidth = array.getDimensionPixelSize(0, -1) 49 | array.recycle() 50 | } 51 | } 52 | 53 | override fun setAdapter(adapter: Adapter<*>?) { 54 | visible() 55 | val oldAdapter = getAdapter() 56 | oldAdapter?.unregisterAdapterDataObserver(mAdapterObserver) 57 | super.setAdapter(adapter) 58 | adapter?.registerAdapterDataObserver(mAdapterObserver) 59 | refreshState() 60 | } 61 | 62 | private fun refreshState() { 63 | adapter?.let { 64 | val noItems = 0 == it.itemCount 65 | if (noItems) { 66 | progressView?.gone() 67 | emptyView?.visible() 68 | gone() 69 | } else { 70 | progressView?.gone() 71 | emptyView?.gone() 72 | visible() 73 | } 74 | } 75 | } 76 | 77 | fun setEmptyView(emptyView: View) { 78 | this.emptyView = emptyView 79 | this.emptyView?.gone() 80 | } 81 | 82 | fun setProgressView(progressView: View) { 83 | this.progressView = progressView 84 | this.progressView?.visible() 85 | } 86 | 87 | fun setEmptyMessage(@StringRes mEmptyMessageResId: Int) { 88 | val emptyText = emptyView?.findViewById(R.id.empty_title) 89 | emptyText?.setText(mEmptyMessageResId) 90 | } 91 | 92 | fun setEmptyIcon(@DrawableRes mEmptyIconResId: Int) { 93 | val emptyImage = emptyView?.findViewById(R.id.empty_image) 94 | emptyImage?.setImageResource(mEmptyIconResId) 95 | } 96 | 97 | fun showLoading() { 98 | emptyView?.gone() 99 | progressView?.visible() 100 | } 101 | 102 | override fun onMeasure(widthSpec: Int, heightSpec: Int) { 103 | super.onMeasure(widthSpec, heightSpec) 104 | if (layoutManager is GridLayoutManager) { 105 | val manager = layoutManager as GridLayoutManager 106 | if (columnWidth > 0) { 107 | val spanCount = max(1, measuredWidth / columnWidth) 108 | manager.spanCount = spanCount 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Observes for changes in the adapter and is triggered on change 115 | */ 116 | private val mAdapterObserver = object : RecyclerView.AdapterDataObserver() { 117 | override fun onChanged() = refreshState() 118 | override fun onItemRangeInserted(positionStart: Int, itemCount: Int) = refreshState() 119 | override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) = refreshState() 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/NewsMapper.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news 2 | 3 | import com.akshay.newsapp.core.mapper.Mapper 4 | import com.akshay.newsapp.news.api.NewsArticle 5 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb 6 | 7 | interface NewsMapper : Mapper { 8 | override fun NewsArticleDb.toRemote(): NewsArticle { 9 | return NewsArticle( 10 | author = author, 11 | title = title, 12 | description = description, 13 | url = url, 14 | urlToImage = urlToImage, 15 | publishedAt = publishedAt, 16 | content = content, 17 | source = NewsArticle.Source(source.id, source.name) 18 | ) 19 | } 20 | 21 | override fun NewsArticle.toStorage(): NewsArticleDb { 22 | return NewsArticleDb( 23 | author = author, 24 | title = title, 25 | description = description, 26 | url = url, 27 | urlToImage = urlToImage, 28 | publishedAt = publishedAt, 29 | content = content, 30 | source = NewsArticleDb.Source(source.id, source.name) 31 | ) 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/api/NewsArticle.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.api 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class NewsArticle( 6 | 7 | @SerializedName("source") 8 | val source: Source, 9 | 10 | /** 11 | * Name of the author for the article 12 | */ 13 | @SerializedName("author") 14 | val author: String? = null, 15 | 16 | /** 17 | * Title of the article 18 | */ 19 | @SerializedName("title") 20 | val title: String? = null, 21 | 22 | /** 23 | * Complete description of the article 24 | */ 25 | @SerializedName("description") 26 | val description: String? = null, 27 | 28 | /** 29 | * URL to the article 30 | */ 31 | @SerializedName("url") 32 | val url: String? = null, 33 | 34 | /** 35 | * URL of the artwork shown with article 36 | */ 37 | @SerializedName("urlToImage") 38 | val urlToImage: String? = null, 39 | 40 | /** 41 | * Date-time when the article was published 42 | */ 43 | @SerializedName("publishedAt") 44 | val publishedAt: String? = null, 45 | 46 | @SerializedName("content") 47 | val content: String? = null 48 | ) { 49 | data class Source( 50 | 51 | @SerializedName("id") 52 | val id: String? = null, 53 | 54 | @SerializedName("name") 55 | val name: String? = null 56 | ) 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/api/NewsResponse.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.api 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | /** 6 | * Describes the response from news service API. 7 | */ 8 | data class NewsResponse( 9 | @SerializedName("status") 10 | val status: String = "", 11 | 12 | @SerializedName("totalResults") 13 | val totalResults: Int = 0, 14 | 15 | @SerializedName("articles") 16 | val articles: List = emptyList() 17 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/api/NewsService.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.api 2 | 3 | import com.akshay.newsapp.BuildConfig 4 | import retrofit2.Response 5 | import retrofit2.http.GET 6 | 7 | /** 8 | * Describes endpoints to fetch the news from NewsAPI. 9 | * 10 | * Read the documentation [here](https://newsapi.org/docs/v2) 11 | */ 12 | interface NewsService { 13 | 14 | /** 15 | * Get top headlines. 16 | * 17 | * See [article documentation](https://newsapi.org/docs/endpoints/top-headlines). 18 | */ 19 | @GET("top-headlines?apiKey=${BuildConfig.NEWS_API_KEY}&category=technology") 20 | suspend fun getTopHeadlines(): Response 21 | 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/di/NewsDatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.di 2 | 3 | import android.app.Application 4 | import com.akshay.newsapp.news.storage.NewsArticlesDao 5 | import com.akshay.newsapp.news.storage.NewsDatabase 6 | import dagger.Module 7 | import dagger.Provides 8 | import dagger.hilt.InstallIn 9 | import dagger.hilt.android.components.ApplicationComponent 10 | import javax.inject.Singleton 11 | 12 | @Module 13 | @InstallIn(ApplicationComponent::class) 14 | object NewsDatabaseModule { 15 | 16 | @Singleton 17 | @Provides 18 | fun provideDb(app: Application): NewsDatabase = NewsDatabase.buildDefault(app) 19 | 20 | @Singleton 21 | @Provides 22 | fun provideUserDao(db: NewsDatabase): NewsArticlesDao = db.newsArticlesDao() 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/di/NewsServiceModule.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.di 2 | 3 | import com.akshay.newsapp.news.api.NewsService 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.android.components.ApplicationComponent 8 | import retrofit2.Retrofit 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | @InstallIn(ApplicationComponent::class) 13 | object NewsServiceModule { 14 | 15 | @Singleton 16 | @Provides 17 | fun provideNewsService(retrofit: Retrofit): NewsService = retrofit.create(NewsService::class.java) 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/domain/NewsRepository.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.domain 2 | 3 | import com.akshay.newsapp.core.ui.ViewState 4 | import com.akshay.newsapp.core.utils.httpError 5 | import com.akshay.newsapp.news.NewsMapper 6 | import com.akshay.newsapp.news.api.NewsResponse 7 | import com.akshay.newsapp.news.api.NewsService 8 | import com.akshay.newsapp.news.storage.NewsArticlesDao 9 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb 10 | import dagger.Binds 11 | import dagger.Module 12 | import dagger.hilt.InstallIn 13 | import dagger.hilt.android.components.ApplicationComponent 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.flow.Flow 16 | import kotlinx.coroutines.flow.emitAll 17 | import kotlinx.coroutines.flow.flow 18 | import kotlinx.coroutines.flow.flowOn 19 | import kotlinx.coroutines.flow.map 20 | import retrofit2.Response 21 | import javax.inject.Inject 22 | import javax.inject.Singleton 23 | 24 | /** 25 | * Repository abstracts the logic of fetching the data and persisting it for 26 | * offline. They are the data source as the single source of truth. 27 | */ 28 | interface NewsRepository { 29 | 30 | /** 31 | * Gets tne cached news article from database and tries to get 32 | * fresh news articles from web and save into database 33 | * if that fails then continues showing cached data. 34 | */ 35 | fun getNewsArticles(): Flow>> 36 | 37 | /** 38 | * Gets fresh news from web. 39 | */ 40 | suspend fun getNewsFromWebservice(): Response 41 | } 42 | 43 | @Singleton 44 | class DefaultNewsRepository @Inject constructor( 45 | private val newsDao: NewsArticlesDao, 46 | private val newsService: NewsService 47 | ) : NewsRepository, NewsMapper { 48 | 49 | override fun getNewsArticles(): Flow>> = flow { 50 | // 1. Start with loading 51 | emit(ViewState.loading()) 52 | 53 | // 2. Try to fetch fresh news from web + cache if any 54 | val freshNews = getNewsFromWebservice() 55 | freshNews.body()?.articles?.toStorage()?.let(newsDao::clearAndCacheArticles) 56 | 57 | // 3. Get news from cache [cache is always source of truth] 58 | val cachedNews = newsDao.getNewsArticles() 59 | emitAll(cachedNews.map { ViewState.success(it) }) 60 | } 61 | .flowOn(Dispatchers.IO) 62 | 63 | override suspend fun getNewsFromWebservice(): Response { 64 | return try { 65 | newsService.getTopHeadlines() 66 | } catch (e: Exception) { 67 | httpError(404) 68 | } 69 | } 70 | } 71 | 72 | @Module 73 | @InstallIn(ApplicationComponent::class) 74 | interface NewsRepositoryModule { 75 | /* Exposes the concrete implementation for the interface */ 76 | @Binds 77 | fun it(it: DefaultNewsRepository): NewsRepository 78 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/storage/NewsArticlesDao.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.storage 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.Query 6 | import androidx.room.Transaction 7 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb 8 | import kotlinx.coroutines.flow.Flow 9 | 10 | /** 11 | * Defines access layer to news articles table 12 | */ 13 | @Dao 14 | interface NewsArticlesDao { 15 | 16 | /** 17 | * Insert articles into the table 18 | */ 19 | @Insert 20 | fun insertArticles(articles: List): List 21 | 22 | @Query("DELETE FROM news_article") 23 | fun clearAllArticles() 24 | 25 | @Transaction 26 | fun clearAndCacheArticles(articles: List) { 27 | clearAllArticles() 28 | insertArticles(articles) 29 | } 30 | 31 | /** 32 | * Get all the articles from table 33 | */ 34 | @Query("SELECT * FROM news_article") 35 | fun getNewsArticles(): Flow> 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/storage/NewsDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.storage 2 | 3 | import android.content.Context 4 | import androidx.annotation.VisibleForTesting 5 | import androidx.room.Database 6 | import androidx.room.Room 7 | import androidx.room.RoomDatabase 8 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb 9 | 10 | @Database( 11 | entities = [NewsArticleDb::class], 12 | version = NewsDatabaseMigration.latestVersion 13 | ) 14 | abstract class NewsDatabase : RoomDatabase() { 15 | 16 | /** 17 | * Get news article DAO 18 | */ 19 | abstract fun newsArticlesDao(): NewsArticlesDao 20 | 21 | companion object { 22 | 23 | private const val databaseName = "news-db" 24 | 25 | fun buildDefault(context: Context) = 26 | Room.databaseBuilder(context, NewsDatabase::class.java, databaseName) 27 | .addMigrations(*NewsDatabaseMigration.allMigrations) 28 | .build() 29 | 30 | @VisibleForTesting 31 | fun buildTest(context: Context) = 32 | Room.inMemoryDatabaseBuilder(context, NewsDatabase::class.java) 33 | .build() 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/storage/NewsDatabaseMigrations.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.storage 2 | 3 | import androidx.room.migration.Migration 4 | import androidx.sqlite.db.SupportSQLiteDatabase 5 | 6 | /** 7 | * Describes migration related to [NewsDatabase]. 8 | */ 9 | internal object NewsDatabaseMigration { 10 | 11 | // Bump this on changing the schema 12 | const val latestVersion = 2 13 | 14 | val allMigrations: Array 15 | get() = arrayOf( 16 | //// Add migrations here 17 | migration_1_2() 18 | ) 19 | 20 | ///** 21 | // * + describe 22 | // * + the 23 | // * + migration 24 | // * + steps 25 | // */ 26 | private fun migration_1_2() = object : Migration(1, 2) { 27 | override fun migrate(database: SupportSQLiteDatabase) { 28 | // Add migration code/SQL here, referencing [V1] and [V2] constants 29 | database.execSQL("ALTER TABLE ${V2.NewsArticle.tableName} ADD COLUMN ${V2.NewsArticle.Column.content} TEXT DEFAULT NULL") 30 | database.execSQL("ALTER TABLE ${V2.NewsArticle.tableName} ADD COLUMN ${V2.NewsArticle.Column.sourceId} TEXT DEFAULT NULL") 31 | database.execSQL("ALTER TABLE ${V2.NewsArticle.tableName} ADD COLUMN ${V2.NewsArticle.Column.sourceName} TEXT DEFAULT \"\"") 32 | } 33 | } 34 | 35 | object V2 { 36 | 37 | object NewsArticle { 38 | const val tableName = "news_article" 39 | 40 | object Column { 41 | const val id = "id" 42 | const val author = "author" 43 | const val title = "title" 44 | const val description = "description" 45 | const val url = "url" 46 | const val urlToImage = "urlToImage" 47 | const val publishedAt = "publishedAt" 48 | const val content = "content" 49 | const val sourceId = "source_id" 50 | const val sourceName = "source_name" 51 | } 52 | } 53 | } 54 | 55 | object V1 { 56 | 57 | object NewsArticle { 58 | const val tableName = "news_article" 59 | 60 | object Column { 61 | const val id = "id" 62 | const val author = "author" 63 | const val title = "title" 64 | const val description = "description" 65 | const val url = "url" 66 | const val urlToImage = "urlToImage" 67 | const val publishedAt = "publishedAt" 68 | } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/storage/entity/NewsArticleDb.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.storage.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Embedded 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb.NewsArticles.Column 8 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb.NewsArticles.tableName 9 | 10 | /** 11 | * Describes how the news article are stored. 12 | */ 13 | @Entity(tableName = tableName) 14 | data class NewsArticleDb( 15 | 16 | /** 17 | * Primary key for Room. 18 | */ 19 | @PrimaryKey(autoGenerate = true) 20 | val id: Int = 0, 21 | 22 | /** 23 | * Name of the author for the article 24 | */ 25 | @ColumnInfo(name = Column.author) 26 | val author: String? = null, 27 | 28 | /** 29 | * Title of the article 30 | */ 31 | @ColumnInfo(name = Column.title) 32 | val title: String? = null, 33 | 34 | /** 35 | * Complete description of the article 36 | */ 37 | @ColumnInfo(name = Column.description) 38 | val description: String? = null, 39 | 40 | /** 41 | * URL to the article 42 | */ 43 | @ColumnInfo(name = Column.url) 44 | val url: String? = null, 45 | 46 | /** 47 | * URL of the artwork shown with article 48 | */ 49 | @ColumnInfo(name = Column.urlToImage) 50 | val urlToImage: String? = null, 51 | 52 | /** 53 | * Date-time when the article was published 54 | */ 55 | @ColumnInfo(name = Column.publishedAt) 56 | val publishedAt: String? = null, 57 | 58 | @Embedded(prefix = "source_") 59 | val source: Source, 60 | 61 | @ColumnInfo(name = Column.content) 62 | val content: String? = null 63 | ) { 64 | 65 | data class Source( 66 | @ColumnInfo(name = Column.sourceId) 67 | val id: String? = null, 68 | 69 | @ColumnInfo(name = Column.sourceName) 70 | val name: String? = null 71 | ) 72 | 73 | object NewsArticles { 74 | const val tableName = "news_article" 75 | 76 | object Column { 77 | const val id = "id" 78 | const val author = "author" 79 | const val title = "title" 80 | const val description = "description" 81 | const val url = "url" 82 | const val urlToImage = "urlToImage" 83 | const val publishedAt = "publishedAt" 84 | const val content = "content" 85 | 86 | const val sourceId = "id" 87 | const val sourceName = "name" 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/ui/activity/NewsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.ui.activity 2 | 3 | import android.os.Bundle 4 | import androidx.activity.viewModels 5 | import androidx.recyclerview.widget.LinearLayoutManager 6 | import com.akshay.newsapp.core.ui.ViewState 7 | import com.akshay.newsapp.core.ui.base.BaseActivity 8 | import com.akshay.newsapp.core.utils.observeNotNull 9 | import com.akshay.newsapp.core.utils.toast 10 | import com.akshay.newsapp.databinding.ActivityMainBinding 11 | import com.akshay.newsapp.news.ui.adapter.NewsArticlesAdapter 12 | import com.akshay.newsapp.news.ui.viewmodel.NewsArticleViewModel 13 | 14 | 15 | class NewsActivity : BaseActivity() { 16 | 17 | private val newsArticleViewModel: NewsArticleViewModel by viewModels() 18 | 19 | private lateinit var binding: ActivityMainBinding 20 | 21 | /** 22 | * Starting point of the activity 23 | */ 24 | override fun onCreate(savedInstanceState: Bundle?) { 25 | super.onCreate(savedInstanceState) 26 | binding = ActivityMainBinding.inflate(layoutInflater) 27 | setContentView(binding.root) 28 | 29 | // Setting up RecyclerView and adapter 30 | binding.newsList.setEmptyView(binding.emptyLayout.emptyView) 31 | binding.newsList.setProgressView(binding.progressLayout.progressView) 32 | 33 | val adapter = NewsArticlesAdapter { toast("Clicked on item") } 34 | binding.newsList.adapter = adapter 35 | binding.newsList.layoutManager = LinearLayoutManager(this) 36 | 37 | // Update the UI on state change 38 | newsArticleViewModel.getNewsArticles().observeNotNull(this) { state -> 39 | when (state) { 40 | is ViewState.Success -> adapter.submitList(state.data) 41 | is ViewState.Loading -> binding.newsList.showLoading() 42 | is ViewState.Error -> toast("Something went wrong ¯\\_(ツ)_/¯ => ${state.message}") 43 | } 44 | } 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/ui/adapter/NewsArticlesAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.ui.adapter 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.DiffUtil 6 | import androidx.recyclerview.widget.ListAdapter 7 | import androidx.recyclerview.widget.RecyclerView 8 | import coil.api.load 9 | import com.akshay.newsapp.R 10 | import com.akshay.newsapp.core.utils.inflate 11 | import com.akshay.newsapp.databinding.RowNewsArticleBinding 12 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb 13 | import com.akshay.newsapp.news.ui.model.NewsAdapterEvent 14 | 15 | /** 16 | * The News adapter to show the news in a list. 17 | */ 18 | class NewsArticlesAdapter( 19 | private val listener: (NewsAdapterEvent) -> Unit 20 | ) : ListAdapter(DIFF_CALLBACK) { 21 | 22 | /** 23 | * Inflate the view 24 | */ 25 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = NewsHolder(parent.inflate(R.layout.row_news_article)) 26 | 27 | /** 28 | * Bind the view with the data 29 | */ 30 | override fun onBindViewHolder(newsHolder: NewsHolder, position: Int) = newsHolder.bind(getItem(position), listener) 31 | 32 | /** 33 | * View Holder Pattern 34 | */ 35 | class NewsHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { 36 | 37 | private val binding = RowNewsArticleBinding.bind(itemView) 38 | 39 | /** 40 | * Binds the UI with the data and handles clicks 41 | */ 42 | fun bind(newsArticle: NewsArticleDb, listener: (NewsAdapterEvent) -> Unit) = with(itemView) { 43 | binding.newsTitle.text = newsArticle.title 44 | binding.newsAuthor.text = newsArticle.author 45 | //TODO: need to format date 46 | //tvListItemDateTime.text = getFormattedDate(newsArticle.publishedAt) 47 | binding.newsPublishedAt.text = newsArticle.publishedAt 48 | binding.newsImage.load(newsArticle.urlToImage) { 49 | placeholder(R.drawable.tools_placeholder) 50 | error(R.drawable.tools_placeholder) 51 | } 52 | setOnClickListener { listener(NewsAdapterEvent.ClickEvent) } 53 | } 54 | } 55 | 56 | companion object { 57 | private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { 58 | override fun areItemsTheSame(oldItem: NewsArticleDb, newItem: NewsArticleDb): Boolean = oldItem.id == newItem.id 59 | override fun areContentsTheSame(oldItem: NewsArticleDb, newItem: NewsArticleDb): Boolean = oldItem == newItem 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/ui/model/NewsAdapterEvent.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.ui.model 2 | 3 | import com.akshay.newsapp.news.ui.adapter.NewsArticlesAdapter 4 | 5 | /** 6 | * Describes all the events originated from 7 | * [NewsArticlesAdapter]. 8 | */ 9 | sealed class NewsAdapterEvent { 10 | 11 | /* Describes item click event */ 12 | object ClickEvent : NewsAdapterEvent() 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/akshay/newsapp/news/ui/viewmodel/NewsArticleViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.ui.viewmodel 2 | 3 | import androidx.hilt.lifecycle.ViewModelInject 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.asLiveData 7 | import com.akshay.newsapp.core.ui.ViewState 8 | import com.akshay.newsapp.news.domain.NewsRepository 9 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb 10 | 11 | /** 12 | * A container for [NewsArticleDb] related data to show on the UI. 13 | */ 14 | class NewsArticleViewModel @ViewModelInject constructor( 15 | newsRepository: NewsRepository 16 | ) : ViewModel() { 17 | 18 | private val newsArticleDb: LiveData>> = newsRepository.getNewsArticles().asLiveData() 19 | 20 | /** 21 | * Return news articles to observeNotNull on the UI. 22 | */ 23 | fun getNewsArticles(): LiveData>> = newsArticleDb 24 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-nodpi/tools_placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/app/src/main/res/drawable-nodpi/tools_placeholder.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable-v21/drawable_list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/drawable_list_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_newspaper.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 23 | 24 | 25 | 29 | 30 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/layout/empty_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 22 | 23 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/layout/progress_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/layout/row_news_article.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 14 | 15 | 16 | 20 | 21 | 28 | 29 | 36 | 37 | 38 | 39 | 40 | 47 | 48 | 49 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #121258 5 | #0F0F4A 6 | #FFC039 7 | 8 | 9 | #33000000 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16dp 4 | 16dp 5 | 6 | 7 | 6sp 8 | 8sp 9 | 10sp 10 | 14sp 11 | 180sp 12 | 12sp 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | News App 4 | 5 | 6 | News Image 7 | news image 8 | 22/02/2017 9 | 10 | 11 | No News 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles_news.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 20 | 21 | 27 | 28 | 31 | 32 | 40 | 41 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app/src/sharedTest/kotlin/com/akshay/newsapp/core/utils/FlowTestUtils.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.utils 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.take 5 | import kotlinx.coroutines.flow.toList 6 | import org.junit.Assert.assertEquals 7 | 8 | /** 9 | * Asserts only the [expected] items by just taking that many from the stream 10 | */ 11 | suspend fun Flow.assertItems(vararg expected: T) { 12 | assertEquals(expected.toList(), this.take(expected.size).toList()) 13 | } 14 | 15 | /** 16 | * Takes all elements from the stream and asserts them. 17 | */ 18 | suspend fun Flow.assertCompleteStream(vararg expected: T) { 19 | assertEquals(expected.toList(), this.toList()) 20 | } -------------------------------------------------------------------------------- /app/src/sharedTest/kotlin/com/akshay/newsapp/core/utils/MockitoTest.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.core.utils 2 | 3 | import org.junit.Before 4 | import org.mockito.MockitoAnnotations 5 | 6 | /** 7 | * Base class to support mocking 8 | * in tests. 9 | * 10 | * Example: 11 | * ``` 12 | * class AbcTest: MockitoTest { 13 | * @Mock 14 | * lateinit var a: Abc 15 | * } 16 | * 17 | * ``` 18 | */ 19 | abstract class MockitoTest { 20 | 21 | @Before 22 | open fun setup() { 23 | MockitoAnnotations.initMocks(this) 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/test/java/com/akshay/newsapp/news/api/BaseServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.api 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import okhttp3.mockwebserver.MockResponse 5 | import okhttp3.mockwebserver.MockWebServer 6 | import okio.buffer 7 | import okio.source 8 | import org.junit.After 9 | import org.junit.Before 10 | import org.junit.Rule 11 | import org.junit.runner.RunWith 12 | import org.junit.runners.JUnit4 13 | import java.io.IOException 14 | import java.nio.charset.StandardCharsets 15 | import java.util.* 16 | 17 | @RunWith(JUnit4::class) 18 | abstract class BaseServiceTest { 19 | 20 | @get:Rule 21 | var instantExecutorRule = InstantTaskExecutorRule() 22 | 23 | protected lateinit var mockWebServer: MockWebServer 24 | 25 | @Before 26 | @Throws(IOException::class) 27 | fun setupMockServer() { 28 | mockWebServer = MockWebServer() 29 | } 30 | 31 | @After 32 | @Throws(IOException::class) 33 | fun stopService() { 34 | mockWebServer.shutdown() 35 | } 36 | 37 | @Throws(IOException::class) 38 | fun enqueueResponse(fileName: String) { 39 | enqueueResponse(fileName, Collections.emptyMap()) 40 | } 41 | 42 | @Throws(IOException::class) 43 | fun enqueueResponse(fileName: String, headers: Map) { 44 | val inputStream = javaClass.classLoader 45 | ?.getResourceAsStream("api-response/$fileName") 46 | val source = inputStream?.source()?.buffer() ?: return 47 | val mockResponse = MockResponse() 48 | for ((key, value) in headers) { 49 | mockResponse.addHeader(key, value) 50 | } 51 | mockWebServer.enqueue(mockResponse.setBody(source.readString(StandardCharsets.UTF_8))) 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /app/src/test/java/com/akshay/newsapp/news/api/NewsSourceServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.api 2 | 3 | import com.akshay.newsapp.core.utils.create 4 | import kotlinx.coroutines.runBlocking 5 | import org.hamcrest.CoreMatchers.`is` 6 | import org.hamcrest.CoreMatchers.notNullValue 7 | import org.hamcrest.MatcherAssert.assertThat 8 | import org.junit.Before 9 | import org.junit.Test 10 | import org.junit.runner.RunWith 11 | import org.junit.runners.JUnit4 12 | import retrofit2.Retrofit 13 | import retrofit2.converter.gson.GsonConverterFactory 14 | import java.io.IOException 15 | 16 | @RunWith(JUnit4::class) 17 | class NewsSourceServiceTest : BaseServiceTest() { 18 | 19 | private lateinit var service: NewsService 20 | 21 | @Before 22 | @Throws(IOException::class) 23 | fun createService() { 24 | service = Retrofit.Builder() 25 | .baseUrl(mockWebServer.url("/")) 26 | .addConverterFactory(GsonConverterFactory.create()) 27 | .build() 28 | .create() 29 | } 30 | 31 | @Test 32 | @Throws(IOException::class, InterruptedException::class) 33 | fun getNewsSource() = runBlocking { 34 | enqueueResponse("news_source.json") 35 | val response = service.getTopHeadlines().body() ?: return@runBlocking 36 | 37 | // Dummy request 38 | mockWebServer.takeRequest() 39 | 40 | // Check news source 41 | assertThat(response, notNullValue()) 42 | assertThat(response.totalResults, `is`(74)) 43 | assertThat(response.status, `is`("ok")) 44 | 45 | // Check list 46 | val articles = response.articles 47 | assertThat(articles, notNullValue()) 48 | 49 | // Check item 1 50 | val article1 = articles[0] 51 | assertThat(article1, notNullValue()) 52 | assertThat(article1.author, `is`("Akshay")) 53 | assertThat(article1.title, `is`("Google Pixel 2")) 54 | assertThat(article1.description, `is`("Gift me Google Pixel 2 ;)")) 55 | assertThat(article1.source.name, `is`("CNN")) 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/test/java/com/akshay/newsapp/news/domain/NewsRepositoryTest.kt: -------------------------------------------------------------------------------- 1 | package com.akshay.newsapp.news.domain 2 | 3 | import com.akshay.newsapp.core.ui.ViewState 4 | import com.akshay.newsapp.core.utils.MockitoTest 5 | import com.akshay.newsapp.core.utils.assertItems 6 | import com.akshay.newsapp.news.api.NewsArticle 7 | import com.akshay.newsapp.news.api.NewsResponse 8 | import com.akshay.newsapp.news.api.NewsService 9 | import com.akshay.newsapp.news.storage.NewsArticlesDao 10 | import com.akshay.newsapp.news.storage.entity.NewsArticleDb 11 | import com.nhaarman.mockitokotlin2.doReturn 12 | import com.nhaarman.mockitokotlin2.doThrow 13 | import com.nhaarman.mockitokotlin2.whenever 14 | import kotlinx.coroutines.flow.flowOf 15 | import kotlinx.coroutines.runBlocking 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | import org.junit.runners.JUnit4 19 | import org.mockito.InjectMocks 20 | import org.mockito.Mock 21 | import retrofit2.Response 22 | 23 | @RunWith(JUnit4::class) 24 | class NewsRepositoryTest : MockitoTest() { 25 | 26 | @Mock 27 | lateinit var newsDao: NewsArticlesDao 28 | 29 | @Mock 30 | lateinit var newsSourceService: NewsService 31 | 32 | @InjectMocks 33 | lateinit var newsRepository: DefaultNewsRepository 34 | 35 | @Test 36 | fun `get news articles from web when there is internet`() = runBlocking { 37 | // GIVEN 38 | val fetchedArticles = listOf( 39 | NewsArticle(title = "Fetched 1", source = NewsArticle.Source()), 40 | NewsArticle(title = "Fetched 2", source = NewsArticle.Source()) 41 | ) 42 | val cachedArticles = listOf( 43 | NewsArticleDb(title = "Fetched 1", source = NewsArticleDb.Source()), 44 | NewsArticleDb(title = "Fetched 2", source = NewsArticleDb.Source()) 45 | ) 46 | val newsSource = NewsResponse(articles = fetchedArticles) 47 | val response = Response.success(newsSource) 48 | 49 | // WHEN 50 | whenever(newsSourceService.getTopHeadlines()) doReturn response 51 | whenever(newsDao.getNewsArticles()) doReturn flowOf(cachedArticles) 52 | 53 | // THEN 54 | newsRepository.getNewsArticles().assertItems( 55 | ViewState.loading(), 56 | ViewState.success(cachedArticles) 57 | ) 58 | } 59 | 60 | @Test 61 | fun `get cached news articles when there is no internet`() = runBlocking { 62 | // GIVEN 63 | val cachedArticles = listOf(NewsArticleDb(title = "Cached", source = NewsArticleDb.Source())) 64 | val error = RuntimeException("Unable to fetch from network") 65 | 66 | // WHEN 67 | whenever(newsSourceService.getTopHeadlines()) doThrow error 68 | whenever(newsDao.getNewsArticles()) doReturn flowOf(cachedArticles) 69 | 70 | // THEN 71 | newsRepository.getNewsArticles().assertItems( 72 | ViewState.loading(), 73 | ViewState.success(cachedArticles) 74 | ) 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/test/resources/api-response/news_source.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "ok", 3 | "totalResults": 74, 4 | "articles": [ 5 | { 6 | "source": { 7 | "id": null, 8 | "name": "CNN" 9 | }, 10 | "author": "Akshay", 11 | "title": "Google Pixel 2", 12 | "description": "Gift me Google Pixel 2 ;)", 13 | "url": "http://store.google.com", 14 | "urlToImage": "https://lh3.googleusercontent.com/fnyMWXMS_wWmgD_YLOBG7ns8JqyyjsB4FkAncTy4--tP0SI3ezixzgrDtrZQx6Wxtim500LxaLeLm93Xa-ThWQ=v1-rw-w900", 15 | "publishedAt": "2017-10-04T08:07:00Z" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /art/dependency-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/art/dependency-graph.png -------------------------------------------------------------------------------- /art/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/art/screen.png -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | // Android SDK version 5 | ext.androidMinSdkVersion = 21 6 | ext.androidTargetSdkVersion = 30 7 | ext.androidCompileSdkVersion = 30 8 | 9 | // Kotlin 10 | ext.kotlinVersion = '1.4.0' 11 | ext.coroutinesVersion = '1.3.9' 12 | ext.ankoVersion = '0.10.4' 13 | 14 | // KTX 15 | ext.liveDataKtx = '2.2.0' 16 | 17 | // Android library 18 | ext.androidGradlePlugin = '4.0.0' 19 | ext.supportVersion = '1.2.0' 20 | ext.recyclerViewVersion = '1.1.0' 21 | ext.cardViewVersion = '1.0.0' 22 | ext.lifecycleVersion = "2.2.0" 23 | ext.roomVersion = "2.2.5" 24 | ext.constraintLayoutVersion = '2.0.1' 25 | 26 | // Networking 27 | ext.retrofitVersion = '2.7.1' 28 | ext.okHttpVersion = '4.3.1' 29 | ext.curlVersion = '0.6.0' 30 | 31 | // Coil 32 | ext.coilVersion = "0.11.0" 33 | 34 | // Hilt + Dagger2 35 | ext.hiltAndroidVersion = "2.28-alpha" 36 | ext.hiltViewModelVersion = "1.0.0-alpha02" 37 | 38 | // KTX 39 | ext.coreKtxVersion = "1.3.1" 40 | ext.fragmentKtxVersion = "1.2.5" 41 | 42 | // Testing 43 | ext.jUnitVersion = '4.12' 44 | ext.androidjUnitVersion = '1.1.2' 45 | ext.mockitoKotlinVersion = '2.2.0' 46 | ext.archTestingVersion = '2.1.0' 47 | ext.espressoVersion = '3.1.0' 48 | ext.testRunnerVersion = '1.0.1' 49 | 50 | // Debug 51 | ext.timberVersion = '4.7.1' 52 | 53 | repositories { 54 | google() 55 | jcenter() 56 | } 57 | dependencies { 58 | classpath "com.android.tools.build:gradle:$androidGradlePlugin" 59 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" 60 | classpath "org.jetbrains.kotlin:kotlin-android-extensions:$kotlinVersion" 61 | classpath "com.google.dagger:hilt-android-gradle-plugin:$hiltAndroidVersion" 62 | 63 | // NOTE: Do not place your application dependencies here; they belong 64 | // in the individual module build.gradle files 65 | } 66 | } 67 | 68 | allprojects { 69 | repositories { 70 | jcenter() 71 | google() 72 | mavenCentral() 73 | } 74 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 75 | kotlinOptions { 76 | jvmTarget = "1.8" 77 | } 78 | } 79 | } 80 | 81 | task clean(type: Delete) { 82 | delete rootProject.buildDir 83 | } 84 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Use the Gradle daemon where available 2 | org.gradle.daemon=true 3 | # Allow tasks to execute in parallel 4 | org.gradle.parallel=true 5 | # Enable new desugaring in process 6 | android.enableD8.desugaring=true 7 | # Faster kapt through Gradle Worker API 8 | kapt.use.worker.api=true 9 | android.useAndroidX=true -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkshayChordiya/News/a538b049b8635220185e454e68e41bd4cfd72138/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Jul 05 19:00:24 CEST 2020 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-6.1.1-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 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | --------------------------------------------------------------------------------