├── .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 | [](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 |
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 | 
57 |
58 | ## Dependency Graph 🔪
59 |
60 | The following diagram shows the dependency graph of the app.
61 |
62 |
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 |
13 |
14 |
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 |
--------------------------------------------------------------------------------