├── .gitignore ├── .travis.yml ├── README.md ├── app ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── news │ │ └── agoda │ │ └── com │ │ └── sample │ │ └── ApplicationTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── news │ │ │ └── agoda │ │ │ └── com │ │ │ └── sample │ │ │ ├── NewsApp.java │ │ │ ├── api │ │ │ ├── ApiConstants.java │ │ │ ├── ApiEndPoint.java │ │ │ ├── NewsApiClient.java │ │ │ ├── NewsViewModelFactory.java │ │ │ ├── RxSingleSchedulers.java │ │ │ └── model │ │ │ │ ├── MediaEntity.java │ │ │ │ ├── NewsEntity.java │ │ │ │ └── NewsList.java │ │ │ ├── base │ │ │ ├── BaseActivity.java │ │ │ └── BaseViewState.java │ │ │ ├── di │ │ │ ├── component │ │ │ │ └── ApplicationComponent.java │ │ │ ├── module │ │ │ │ ├── ActivityBindingModule.java │ │ │ │ ├── ApiModule.java │ │ │ │ ├── ApplicationModule.java │ │ │ │ ├── RxModule.java │ │ │ │ └── ViewModelModule.java │ │ │ └── scope │ │ │ │ ├── AppScope.java │ │ │ │ └── ViewModelKey.java │ │ │ ├── ui │ │ │ ├── DetailViewActivity.java │ │ │ ├── MainActivity.java │ │ │ ├── NewsAdapter.java │ │ │ ├── callback │ │ │ │ └── IItemClick.java │ │ │ └── viewmodel │ │ │ │ ├── NewsListViewState.java │ │ │ │ └── NewsViewModel.java │ │ │ └── util │ │ │ ├── AppConstants.java │ │ │ ├── MediaDeserializer.java │ │ │ └── Utilities.java │ └── res │ │ ├── drawable │ │ └── place_holder.png │ │ ├── layout │ │ ├── activity_detail.xml │ │ ├── activity_main.xml │ │ └── list_item_news.xml │ │ ├── menu │ │ └── menu_main.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── values-sw600dp │ │ └── dimens.xml │ │ ├── values-w820dp │ │ └── dimens.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── news │ └── agoda │ └── com │ └── sample │ ├── APIAvailabilityTest.java │ ├── api │ └── model │ │ ├── MediaEntityTest.java │ │ └── NewsEntityTest.java │ └── ui │ └── viewmodel │ └── NewsViewModelTest.java ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | /.idea 4 | .DS_Store 5 | /build 6 | /captures 7 | **/*.iml 8 | **/build 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: android 2 | jdk: 3 | - oraclejdk7 4 | 5 | android: 6 | components: 7 | - tools 8 | - platform-tools 9 | - build-tools-23.0.3 10 | - extra-android-m2repository 11 | - android-23 12 | 13 | script: ./gradlew clean :app:testDebugUnitTest -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a sample News Reader app that is supposed to display news list and the details. 2 | The first page displays news list, when one of the items is clicked, it is supposed to show the detail of the selected news. 3 | Unfortunately, the app is full of bugs and it crashes as soon as it is launched. 4 | Also, the code is not properly written and there are no unit tests. 5 | Can you help to fix all the problems? 6 | 7 | ## Before you start 8 | This project requires the following 9 | 10 | 1. Android Studio 2.1 11 | 2. Android SDK 23 or above. 12 | 3. Android SDK build tools 23.0.3 or above. 13 | 14 | ## Screenshots 15 | The screenshot below shows how the app looks like when it is done. 16 | 17 | ![](http://i.imgur.com/GgEP7FM.jpg) 18 | ![](http://i.imgur.com/yAtzntJ.jpg) 19 | 20 | ## About the project 21 | All the data is coming from the web endpoint. 22 | The response contains a list of news items as well as URLs to the pictures associated with each story. 23 | 24 | ## Fix crashes in News List page 25 | Can you help fix all bugs so that it can display news list properly? 26 | 27 | ## Fix crashes in News Detail page 28 | Can you help fix all bugs so that the app can show news detail properly? Also, clicking on "Full Story" button, it should open a browser and display full story in the browser. 29 | 30 | ## Basic unit test 31 | Can you help to write unit tests for MediaEntity and NewsEntity? 32 | 33 | ## Improvements 34 | 1. The main logic is written in MainActivity, which is not a very clean way to construct an app. Can you help to improve it? 35 | 36 | 2. The way of fetching and parsing JSON data is not very nice. For example, if one of the name/value is missing, it 37 | can cause the app to crash. 38 | 39 | 3. The layout is only suitable for phones. Can you create an immersive tablet experience? 40 | 41 | Can you help to make it better? 42 | 43 | ## Notes 44 | 1. It is possible that some of the stories do not have images. 45 | 2. It is possible that the link to the full story might not work as it is controlled by New York Times. 46 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 28 5 | 6 | defaultConfig { 7 | applicationId "news.agoda.com.technewssample" 8 | minSdkVersion 21 9 | targetSdkVersion 28 10 | versionCode 1 11 | versionName "1.0" 12 | } 13 | compileOptions { 14 | sourceCompatibility JavaVersion.VERSION_1_8 15 | targetCompatibility JavaVersion.VERSION_1_8 16 | } 17 | dataBinding { 18 | enabled = true 19 | } 20 | testOptions { 21 | unitTests.returnDefaultValues = true 22 | } 23 | 24 | buildTypes { 25 | release { 26 | minifyEnabled false 27 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 28 | } 29 | } 30 | } 31 | 32 | def supportVersion = '28.0.0' 33 | def retrofitVersion = '2.3.0' 34 | def rxJavaVersion = '2.1.0' 35 | def daggerVersion = '2.19' 36 | def mockitoVersion = '2.19.0' 37 | def gsonVersion = '2.6.2' 38 | def frescoVersion = '1.11.0' 39 | def hamcrestVersion = '2.1.0' 40 | 41 | dependencies { 42 | implementation fileTree(dir: 'libs', include: ['*.jar']) 43 | implementation "com.android.support:appcompat-v7:$supportVersion" 44 | implementation "com.android.support:support-compat:$supportVersion" 45 | implementation "com.android.support:support-v4:$supportVersion" 46 | implementation "com.android.support:design:$supportVersion" 47 | implementation "com.android.support:support-core-utils:$supportVersion" 48 | implementation "com.android.support:support-core-ui:$supportVersion" 49 | implementation "com.android.support:support-fragment:$supportVersion" 50 | implementation "com.android.support:support-v13:$supportVersion" 51 | implementation "com.android.support:cardview-v7:$supportVersion" 52 | implementation "com.android.support:recyclerview-v7:$supportVersion" 53 | implementation "com.android.support:support-annotations:$supportVersion" 54 | 55 | implementation "android.arch.lifecycle:extensions:1.1.1" 56 | annotationProcessor "android.arch.lifecycle:compiler:1.1.1" 57 | testImplementation "android.arch.core:core-testing:1.1.1" 58 | 59 | implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" 60 | implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" 61 | implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion" 62 | 63 | implementation "io.reactivex.rxjava2:rxandroid:$rxJavaVersion" 64 | implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" 65 | 66 | implementation "com.google.dagger:dagger:$daggerVersion" 67 | annotationProcessor "com.google.dagger:dagger-compiler:$daggerVersion" 68 | implementation "com.google.dagger:dagger-android-support:$daggerVersion" 69 | annotationProcessor "com.google.dagger:dagger-android-processor:$daggerVersion" 70 | 71 | implementation "com.google.code.gson:gson:$gsonVersion" 72 | implementation "com.facebook.fresco:fresco:$frescoVersion" 73 | 74 | testImplementation 'junit:junit:4.12' 75 | testImplementation "org.mockito:mockito-core:$mockitoVersion" 76 | testImplementation 'org.hamcrest:hamcrest-library:1.3' 77 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 78 | } 79 | -------------------------------------------------------------------------------- /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 /Volumes/Data/adt-mac/sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -------------------------------------------------------------------------------- /app/src/androidTest/java/news/agoda/com/sample/ApplicationTest.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample; 2 | 3 | import android.app.Application; 4 | import android.test.ApplicationTestCase; 5 | 6 | /** 7 | * Testing Fundamentals 8 | */ 9 | public class ApplicationTest extends ApplicationTestCase { 10 | public ApplicationTest() { 11 | super(Application.class); 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/NewsApp.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample; 2 | 3 | import android.app.Activity; 4 | import android.app.Application; 5 | 6 | import javax.inject.Inject; 7 | 8 | import dagger.android.AndroidInjector; 9 | import dagger.android.DispatchingAndroidInjector; 10 | import dagger.android.HasActivityInjector; 11 | import news.agoda.com.sample.di.component.DaggerApplicationComponent; 12 | 13 | public class NewsApp extends Application implements HasActivityInjector { 14 | 15 | @Inject 16 | DispatchingAndroidInjector activityDispatchingAndroidInjector; 17 | 18 | @Override 19 | public void onCreate() { 20 | super.onCreate(); 21 | DaggerApplicationComponent 22 | .builder() 23 | .application(this) 24 | .build() 25 | .inject(this); 26 | } 27 | 28 | @Override 29 | public AndroidInjector activityInjector() { 30 | return activityDispatchingAndroidInjector; 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/api/ApiConstants.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.api; 2 | 3 | 4 | public class ApiConstants { 5 | 6 | 7 | public static final String BASE_URL = "https://api.myjson.com/"; 8 | public static final String NEWS_URL = "bins/nl6jh"; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/api/ApiEndPoint.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.api; 2 | 3 | import io.reactivex.Single; 4 | import news.agoda.com.sample.api.model.NewsList; 5 | import retrofit2.http.GET; 6 | 7 | public interface ApiEndPoint { 8 | 9 | @GET(ApiConstants.NEWS_URL) 10 | Single fetchNewsList(); 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/api/NewsApiClient.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.api; 2 | 3 | 4 | import javax.inject.Inject; 5 | 6 | import io.reactivex.Single; 7 | import news.agoda.com.sample.api.model.NewsList; 8 | 9 | public class NewsApiClient { 10 | 11 | private final ApiEndPoint api; 12 | 13 | @Inject 14 | public NewsApiClient(ApiEndPoint api) { 15 | this.api = api; 16 | } 17 | 18 | public Single fetchNews() { 19 | return api.fetchNewsList(); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/api/NewsViewModelFactory.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.api; 2 | 3 | import android.arch.lifecycle.ViewModel; 4 | import android.arch.lifecycle.ViewModelProvider; 5 | 6 | import java.util.Map; 7 | 8 | import javax.inject.Inject; 9 | import javax.inject.Provider; 10 | 11 | import news.agoda.com.sample.di.scope.AppScope; 12 | 13 | @AppScope 14 | public class NewsViewModelFactory implements ViewModelProvider.Factory { 15 | private final Map, Provider> creators; 16 | 17 | @Inject 18 | NewsViewModelFactory(final Map, Provider> creators) { 19 | this.creators = creators; 20 | } 21 | 22 | @SuppressWarnings("unchecked") 23 | @Override 24 | public T create(final Class modelClass) { 25 | Provider creator = creators.get(modelClass); 26 | if (creator == null) { 27 | for (final Map.Entry, Provider> entry : creators.entrySet()) { 28 | if (modelClass.isAssignableFrom(entry.getKey())) { 29 | creator = entry.getValue(); 30 | break; 31 | } 32 | } 33 | } 34 | if (creator == null) { 35 | throw new IllegalArgumentException("unknown model class " + modelClass); 36 | } 37 | try { 38 | return (T) creator.get(); 39 | } catch (final Exception e) { 40 | throw new RuntimeException(e); 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/api/RxSingleSchedulers.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.api; 2 | 3 | import io.reactivex.SingleTransformer; 4 | import io.reactivex.android.schedulers.AndroidSchedulers; 5 | import io.reactivex.schedulers.Schedulers; 6 | 7 | public interface RxSingleSchedulers { 8 | RxSingleSchedulers DEFAULT = new RxSingleSchedulers() { 9 | @Override 10 | public SingleTransformer applySchedulers() { 11 | return single -> single 12 | .subscribeOn(Schedulers.io()) 13 | .observeOn(AndroidSchedulers.mainThread()); 14 | } 15 | }; 16 | 17 | RxSingleSchedulers TEST_SCHEDULER = new RxSingleSchedulers() { 18 | @Override 19 | public SingleTransformer applySchedulers() { 20 | return single -> single 21 | .subscribeOn(Schedulers.trampoline()) 22 | .observeOn(Schedulers.trampoline()); 23 | } 24 | }; 25 | 26 | SingleTransformer applySchedulers(); 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/api/model/MediaEntity.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.api.model; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | 5 | /** 6 | * This class represents a media item 7 | */ 8 | public class MediaEntity { 9 | 10 | public MediaEntity(String url, String format, int height, int width, String type, String subType, String caption, String copyright) { 11 | this.url = url; 12 | this.format = format; 13 | this.height = height; 14 | this.width = width; 15 | this.type = type; 16 | this.subType = subType; 17 | this.caption = caption; 18 | this.copyright = copyright; 19 | } 20 | 21 | @SerializedName("url") 22 | private String url; 23 | 24 | @SerializedName("format") 25 | private String format; 26 | 27 | @SerializedName("height") 28 | private int height; 29 | 30 | @SerializedName("width") 31 | private int width; 32 | 33 | @SerializedName("type") 34 | private String type; 35 | 36 | @SerializedName("subtype") 37 | private String subType; 38 | 39 | @SerializedName("caption") 40 | private String caption; 41 | 42 | @SerializedName("copyright") 43 | private String copyright; 44 | 45 | public String getUrl() { 46 | return url; 47 | } 48 | 49 | public String getFormat() { 50 | return format; 51 | } 52 | 53 | public int getHeight() { 54 | return height; 55 | } 56 | 57 | public int getWidth() { 58 | return width; 59 | } 60 | 61 | public String getType() { 62 | return type; 63 | } 64 | 65 | public String getSubType() { 66 | return subType; 67 | } 68 | 69 | public String getCaption() { 70 | return caption; 71 | } 72 | 73 | public String getCopyright() { 74 | return copyright; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/api/model/NewsEntity.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.api.model; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * This represents a news item 9 | */ 10 | public class NewsEntity { 11 | 12 | 13 | @SerializedName("title") 14 | private String title; 15 | 16 | @SerializedName("abstract") 17 | private String summary; 18 | 19 | @SerializedName("url") 20 | private String articleUrl; 21 | 22 | @SerializedName("byline") 23 | private String byline; 24 | 25 | @SerializedName("published_date") 26 | private String publishedDate; 27 | 28 | @SerializedName("multimedia") 29 | private List mediaEntityList; 30 | 31 | public String getTitle() { 32 | return title; 33 | } 34 | 35 | public String getSummary() { 36 | return summary; 37 | } 38 | 39 | public String getArticleUrl() { 40 | return articleUrl; 41 | } 42 | 43 | public String getByline() { 44 | return byline; 45 | } 46 | 47 | public String getPublishedDate() { 48 | return publishedDate; 49 | } 50 | 51 | public List getMediaEntityList() { 52 | return mediaEntityList; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/api/model/NewsList.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.api.model; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | 5 | import java.util.List; 6 | 7 | public class NewsList { 8 | 9 | @SerializedName("section") 10 | private String section; 11 | 12 | @SerializedName("num_results") 13 | private String totalResults; 14 | 15 | @SerializedName("results") 16 | private List results; 17 | 18 | 19 | public String getSection() { 20 | return section; 21 | } 22 | 23 | public String getTotalResults() { 24 | return totalResults; 25 | } 26 | 27 | public List getResults() { 28 | return results; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/base/BaseActivity.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.base; 2 | 3 | import android.databinding.DataBindingUtil; 4 | import android.databinding.ViewDataBinding; 5 | import android.os.Bundle; 6 | import android.support.annotation.Nullable; 7 | import android.support.v7.app.AppCompatActivity; 8 | import android.widget.Toast; 9 | 10 | import dagger.android.AndroidInjection; 11 | 12 | public abstract class BaseActivity extends AppCompatActivity { 13 | 14 | protected B binding; 15 | 16 | @Override 17 | protected void onCreate(@Nullable Bundle savedInstanceState) { 18 | AndroidInjection.inject(this); 19 | binding = DataBindingUtil.setContentView(this, getLayout()); 20 | super.onCreate(savedInstanceState); 21 | } 22 | 23 | public void showToast(String message) { 24 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); 25 | } 26 | 27 | public abstract int getLayout(); 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/base/BaseViewState.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.base; 2 | 3 | public class BaseViewState { 4 | public T getData() { 5 | return data; 6 | } 7 | 8 | public void setData(T data) { 9 | this.data = data; 10 | } 11 | 12 | public Throwable getError() { 13 | return error; 14 | } 15 | 16 | public void setError(Throwable error) { 17 | this.error = error; 18 | } 19 | 20 | public int getCurrentState() { 21 | return currentState; 22 | } 23 | 24 | public void setCurrentState(int currentState) { 25 | this.currentState = currentState; 26 | } 27 | 28 | protected T data; 29 | protected Throwable error; 30 | protected int currentState; 31 | 32 | public enum State{ 33 | LOADING(0), SUCCESS(1),FAILED(-1); 34 | public int value; 35 | State(int val) { 36 | value = val; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/di/component/ApplicationComponent.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.di.component; 2 | 3 | import dagger.BindsInstance; 4 | import dagger.Component; 5 | import dagger.android.AndroidInjector; 6 | import dagger.android.support.AndroidSupportInjectionModule; 7 | import news.agoda.com.sample.NewsApp; 8 | import news.agoda.com.sample.di.module.ActivityBindingModule; 9 | import news.agoda.com.sample.di.module.ApiModule; 10 | import news.agoda.com.sample.di.module.ApplicationModule; 11 | import news.agoda.com.sample.di.module.RxModule; 12 | import news.agoda.com.sample.di.scope.AppScope; 13 | 14 | @AppScope 15 | @Component(modules = {ApplicationModule.class, 16 | AndroidSupportInjectionModule.class, 17 | ActivityBindingModule.class, 18 | ApiModule.class, RxModule.class}) 19 | public interface ApplicationComponent extends AndroidInjector { 20 | 21 | void inject(NewsApp application); 22 | 23 | @Component.Builder 24 | interface Builder { 25 | @BindsInstance 26 | Builder application(NewsApp application); 27 | ApplicationComponent build(); 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/di/module/ActivityBindingModule.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.di.module; 2 | 3 | 4 | import dagger.Module; 5 | import dagger.android.ContributesAndroidInjector; 6 | import news.agoda.com.sample.ui.DetailViewActivity; 7 | import news.agoda.com.sample.ui.MainActivity; 8 | 9 | @Module 10 | public abstract class ActivityBindingModule { 11 | 12 | @ContributesAndroidInjector() 13 | abstract MainActivity bindMainActivity(); 14 | 15 | @ContributesAndroidInjector() 16 | abstract DetailViewActivity bindDetailActivity(); 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/di/module/ApiModule.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.di.module; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | import com.google.gson.reflect.TypeToken; 6 | 7 | import java.util.List; 8 | 9 | import dagger.Module; 10 | import dagger.Provides; 11 | import news.agoda.com.sample.api.ApiConstants; 12 | import news.agoda.com.sample.api.ApiEndPoint; 13 | import news.agoda.com.sample.api.model.MediaEntity; 14 | import news.agoda.com.sample.di.scope.AppScope; 15 | import news.agoda.com.sample.util.MediaDeserializer; 16 | import retrofit2.Retrofit; 17 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; 18 | import retrofit2.converter.gson.GsonConverterFactory; 19 | 20 | @Module 21 | public class ApiModule { 22 | 23 | @AppScope 24 | @Provides 25 | Retrofit provideRetrofit(Gson gson) { 26 | return new Retrofit.Builder().baseUrl(ApiConstants.BASE_URL) 27 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 28 | .addConverterFactory(GsonConverterFactory.create(gson)) 29 | .build(); 30 | } 31 | 32 | @AppScope 33 | @Provides 34 | Gson provideGson() { 35 | return new GsonBuilder().registerTypeAdapter(new TypeToken>(){}.getType(), new MediaDeserializer()).create(); 36 | } 37 | 38 | 39 | @AppScope 40 | @Provides 41 | ApiEndPoint provideNewsApi(Retrofit retrofit) { 42 | return retrofit.create(ApiEndPoint.class); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/di/module/ApplicationModule.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.di.module; 2 | 3 | import android.app.Application; 4 | import android.content.Context; 5 | 6 | import dagger.Binds; 7 | import dagger.Module; 8 | 9 | 10 | @Module(includes = ViewModelModule.class) 11 | abstract public class ApplicationModule { 12 | 13 | @Binds 14 | abstract Context provideContext(Application application); 15 | 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/di/module/RxModule.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.di.module; 2 | 3 | import dagger.Module; 4 | import dagger.Provides; 5 | import news.agoda.com.sample.api.RxSingleSchedulers; 6 | import news.agoda.com.sample.di.scope.AppScope; 7 | 8 | @Module 9 | public class RxModule { 10 | @AppScope 11 | @Provides 12 | public RxSingleSchedulers providesScheduler() { 13 | return RxSingleSchedulers.DEFAULT; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/di/module/ViewModelModule.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.di.module; 2 | 3 | import android.arch.lifecycle.ViewModel; 4 | import android.arch.lifecycle.ViewModelProvider; 5 | 6 | import dagger.Binds; 7 | import dagger.Module; 8 | import dagger.multibindings.IntoMap; 9 | import news.agoda.com.sample.api.NewsViewModelFactory; 10 | import news.agoda.com.sample.di.scope.ViewModelKey; 11 | import news.agoda.com.sample.ui.viewmodel.NewsViewModel; 12 | 13 | 14 | @Module 15 | public abstract class ViewModelModule { 16 | 17 | @Binds 18 | @IntoMap 19 | @ViewModelKey(NewsViewModel.class) 20 | abstract ViewModel bindNewsViewModel(NewsViewModel searchViewModel); 21 | 22 | 23 | @Binds 24 | abstract ViewModelProvider.Factory bindNewsViewModelFactory(NewsViewModelFactory factory); 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/di/scope/AppScope.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.di.scope; 2 | 3 | import java.lang.annotation.Retention; 4 | import java.lang.annotation.RetentionPolicy; 5 | 6 | import javax.inject.Scope; 7 | 8 | @Scope 9 | @Retention(RetentionPolicy.RUNTIME) 10 | public @interface AppScope { 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/di/scope/ViewModelKey.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.di.scope; 2 | 3 | 4 | import android.arch.lifecycle.ViewModel; 5 | 6 | import java.lang.annotation.Documented; 7 | import java.lang.annotation.ElementType; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | import java.lang.annotation.Target; 11 | 12 | import dagger.MapKey; 13 | 14 | @Documented 15 | @MapKey 16 | @Target(ElementType.METHOD) 17 | @Retention(RetentionPolicy.RUNTIME) 18 | public @interface ViewModelKey { 19 | 20 | Class value(); 21 | 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/ui/DetailViewActivity.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.ui; 2 | 3 | import android.content.Context; 4 | import android.content.Intent; 5 | import android.net.Uri; 6 | import android.os.Bundle; 7 | 8 | import com.facebook.drawee.backends.pipeline.Fresco; 9 | import com.facebook.drawee.interfaces.DraweeController; 10 | import com.facebook.imagepipeline.request.ImageRequest; 11 | 12 | import news.agoda.com.sample.R; 13 | import news.agoda.com.sample.base.BaseActivity; 14 | import news.agoda.com.sample.databinding.ActivityDetailBinding; 15 | import news.agoda.com.sample.util.AppConstants; 16 | import news.agoda.com.sample.util.Utilities; 17 | 18 | /** 19 | * News detail view 20 | */ 21 | public class DetailViewActivity extends BaseActivity { 22 | private String storyURL = ""; 23 | 24 | public static void openDetailViewActivity(Context context,Bundle bundle ){ 25 | Intent intent = new Intent(context, DetailViewActivity.class); 26 | intent.putExtras(bundle); 27 | context.startActivity(intent); 28 | } 29 | 30 | @Override 31 | protected void onCreate(Bundle savedInstanceState) { 32 | super.onCreate(savedInstanceState); 33 | Bundle extras = getIntent().getExtras(); 34 | storyURL = extras.getString(AppConstants.URL); 35 | String title = extras.getString(AppConstants.TITLE); 36 | String summary = extras.getString(AppConstants.SUMMARY); 37 | String imageURL = ""; 38 | if (extras.containsKey(AppConstants.IMAGEURL)) 39 | imageURL = extras.getString(AppConstants.IMAGEURL); 40 | binding.setTitle(title); 41 | binding.setSummary(summary); 42 | DraweeController draweeController = Fresco.newDraweeControllerBuilder() 43 | .setImageRequest(ImageRequest.fromUri(Uri.parse(imageURL))) 44 | .setOldController(binding.newsImage.getController()).build(); 45 | binding.newsImage.setController(draweeController); 46 | binding.setStoryClick(click -> { 47 | if (Utilities.isNetworkConnected(this)) 48 | openCompleteNews(); 49 | else 50 | showToast(getString(R.string.internet_error)); 51 | }); 52 | } 53 | 54 | @Override 55 | public int getLayout() { 56 | return R.layout.activity_detail; 57 | } 58 | 59 | private void openCompleteNews() { 60 | Intent intent = new Intent(Intent.ACTION_VIEW); 61 | intent.setData(Uri.parse(storyURL)); 62 | startActivity(intent); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/ui/MainActivity.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.ui; 2 | 3 | import android.arch.lifecycle.ViewModelProviders; 4 | import android.os.Bundle; 5 | import android.support.v7.widget.LinearLayoutManager; 6 | 7 | import com.facebook.drawee.backends.pipeline.Fresco; 8 | 9 | import java.util.ArrayList; 10 | 11 | import javax.inject.Inject; 12 | 13 | import news.agoda.com.sample.R; 14 | import news.agoda.com.sample.api.NewsViewModelFactory; 15 | import news.agoda.com.sample.api.model.NewsEntity; 16 | import news.agoda.com.sample.api.model.NewsList; 17 | import news.agoda.com.sample.base.BaseActivity; 18 | import news.agoda.com.sample.databinding.ActivityMainBinding; 19 | import news.agoda.com.sample.ui.callback.IItemClick; 20 | import news.agoda.com.sample.ui.viewmodel.NewsViewModel; 21 | import news.agoda.com.sample.util.AppConstants; 22 | 23 | public class MainActivity extends BaseActivity implements IItemClick { 24 | private NewsAdapter newsAdapter; 25 | private NewsViewModel newsViewModel; 26 | 27 | @Inject 28 | NewsViewModelFactory newsViewModelFactory; 29 | 30 | @Override 31 | protected void onCreate(Bundle savedInstanceState) { 32 | super.onCreate(savedInstanceState); 33 | Fresco.initialize(this); 34 | newsViewModel = ViewModelProviders.of(this, newsViewModelFactory).get(NewsViewModel.class); 35 | initNewsDataAdapter(); 36 | observeDataChange(); 37 | newsViewModel.fetchNews(); 38 | } 39 | 40 | private void loadNewsData(NewsList data) { 41 | newsAdapter.addNewsList(data.getResults()); 42 | } 43 | 44 | @Override 45 | public int getLayout() { 46 | return R.layout.activity_main; 47 | } 48 | 49 | private void initNewsDataAdapter() { 50 | newsAdapter = new NewsAdapter(this, new ArrayList<>()); 51 | binding.rvNewsList.setLayoutManager(new LinearLayoutManager(this)); 52 | binding.rvNewsList.setAdapter(newsAdapter); 53 | newsAdapter.addListener(this); 54 | } 55 | 56 | private void observeDataChange() { 57 | newsViewModel.getNewsListState().observe(this, newsListViewState -> { 58 | switch (newsListViewState.getCurrentState()) { 59 | case 0: 60 | binding.setShowLoading(true); 61 | break; 62 | case 1: 63 | binding.setShowLoading(false); 64 | loadNewsData(newsListViewState.getData()); 65 | break; 66 | case -1: // show error 67 | binding.setShowLoading(false); 68 | break; 69 | } 70 | }); 71 | } 72 | 73 | @Override 74 | public void onItemClick(NewsEntity newsEntity) { 75 | Bundle bundle = new Bundle(); 76 | bundle.putString(AppConstants.TITLE, newsEntity.getTitle()); 77 | bundle.putString(AppConstants.URL, newsEntity.getArticleUrl()); 78 | bundle.putString(AppConstants.SUMMARY, newsEntity.getSummary()); 79 | if (!newsEntity.getMediaEntityList().isEmpty()) 80 | bundle.putString(AppConstants.IMAGEURL, newsEntity.getMediaEntityList().get(0).getUrl()); 81 | 82 | DetailViewActivity.openDetailViewActivity(this, bundle); 83 | } 84 | 85 | @Override 86 | protected void onDestroy() { 87 | super.onDestroy(); 88 | if (newsAdapter != null) { 89 | newsAdapter.removeListener(); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/ui/NewsAdapter.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.ui; 2 | 3 | import android.content.Context; 4 | import android.net.Uri; 5 | import android.support.annotation.NonNull; 6 | import android.support.v7.widget.RecyclerView; 7 | import android.view.LayoutInflater; 8 | import android.view.ViewGroup; 9 | 10 | import com.facebook.drawee.backends.pipeline.Fresco; 11 | import com.facebook.drawee.interfaces.DraweeController; 12 | import com.facebook.imagepipeline.request.ImageRequest; 13 | 14 | import java.util.List; 15 | 16 | import news.agoda.com.sample.R; 17 | import news.agoda.com.sample.api.model.MediaEntity; 18 | import news.agoda.com.sample.api.model.NewsEntity; 19 | import news.agoda.com.sample.databinding.ListItemNewsBinding; 20 | import news.agoda.com.sample.ui.callback.IItemClick; 21 | 22 | public class NewsAdapter extends RecyclerView.Adapter { 23 | private List newsList; 24 | private LayoutInflater layoutInflater; 25 | private IItemClick listener; 26 | 27 | public NewsAdapter(Context context, List newsList) { 28 | this.newsList = newsList; 29 | layoutInflater = LayoutInflater.from(context); 30 | } 31 | 32 | public void addListener(IItemClick itemClick) { 33 | this.listener = itemClick; 34 | } 35 | 36 | public void removeListener() { 37 | listener = null; 38 | } 39 | 40 | public void addNewsList(List newsList) { 41 | if (!this.newsList.isEmpty()) { 42 | this.newsList.clear(); 43 | } 44 | this.newsList.addAll(newsList); 45 | notifyDataSetChanged(); 46 | } 47 | 48 | @NonNull 49 | @Override 50 | public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { 51 | return new ViewHolder(ListItemNewsBinding.inflate(layoutInflater, viewGroup, false)); 52 | } 53 | 54 | @Override 55 | public void onBindViewHolder(@NonNull ViewHolder viewHolder, int i) { 56 | final NewsEntity newsEntity = newsList.get(i); 57 | viewHolder.binding.setNews(newsEntity); 58 | List mediaEntityList = newsEntity.getMediaEntityList(); 59 | if (!mediaEntityList.isEmpty()) { 60 | MediaEntity mediaEntity = mediaEntityList.get(0); 61 | String thumbnailURL = mediaEntity.getUrl(); 62 | 63 | DraweeController draweeController = Fresco.newDraweeControllerBuilder().setImageRequest(ImageRequest.fromUri(Uri.parse(thumbnailURL))).setOldController(viewHolder.binding.newsItemImage.getController()).build(); 64 | viewHolder.binding.newsItemImage.setController(draweeController); 65 | } else { 66 | viewHolder.binding.newsItemImage.setImageResource(R.mipmap.ic_launcher); 67 | } 68 | viewHolder.binding.setItemClickListener(click -> listener.onItemClick(newsEntity)); 69 | } 70 | 71 | @Override 72 | public int getItemCount() { 73 | return newsList.size(); 74 | } 75 | 76 | static final class ViewHolder extends RecyclerView.ViewHolder { 77 | private final ListItemNewsBinding binding; 78 | 79 | public ViewHolder(ListItemNewsBinding binding) { 80 | super(binding.getRoot()); 81 | this.binding = binding; 82 | } 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/ui/callback/IItemClick.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.ui.callback; 2 | 3 | public interface IItemClick { 4 | void onItemClick(T item); 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/ui/viewmodel/NewsListViewState.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.ui.viewmodel; 2 | 3 | import news.agoda.com.sample.api.model.NewsList; 4 | import news.agoda.com.sample.base.BaseViewState; 5 | 6 | public class NewsListViewState extends BaseViewState { 7 | private NewsListViewState(NewsList data, int currentState, Throwable error) { 8 | this.data = data; 9 | this.error = error; 10 | this.currentState = currentState; 11 | } 12 | 13 | public static NewsListViewState ERROR_STATE = new NewsListViewState(null, State.FAILED.value, new Throwable()); 14 | public static NewsListViewState LOADING_STATE = new NewsListViewState(null, State.LOADING.value, null); 15 | public static NewsListViewState SUCCESS_STATE = new NewsListViewState(new NewsList(), State.SUCCESS.value, null); 16 | 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/ui/viewmodel/NewsViewModel.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.ui.viewmodel; 2 | 3 | import android.arch.lifecycle.MutableLiveData; 4 | import android.arch.lifecycle.ViewModel; 5 | 6 | import javax.inject.Inject; 7 | 8 | import io.reactivex.disposables.CompositeDisposable; 9 | import news.agoda.com.sample.api.NewsApiClient; 10 | import news.agoda.com.sample.api.RxSingleSchedulers; 11 | import news.agoda.com.sample.api.model.NewsList; 12 | 13 | public class NewsViewModel extends ViewModel { 14 | 15 | private CompositeDisposable disposable; 16 | private final NewsApiClient apiClient; 17 | private final RxSingleSchedulers rxSingleSchedulers; 18 | private final MutableLiveData newsListState = new MutableLiveData<>(); 19 | 20 | public MutableLiveData getNewsListState() { 21 | return newsListState; 22 | } 23 | 24 | @Inject 25 | public NewsViewModel(NewsApiClient apiClient, RxSingleSchedulers rxSingleSchedulers) { 26 | this.apiClient = apiClient; 27 | this.rxSingleSchedulers = rxSingleSchedulers; 28 | disposable = new CompositeDisposable(); 29 | } 30 | 31 | public void fetchNews() { 32 | disposable.add(apiClient.fetchNews() 33 | .doOnEvent((newsList, throwable) -> onLoading()) 34 | .compose(rxSingleSchedulers.applySchedulers()) 35 | .subscribe(this::onSuccess, 36 | this::onError)); 37 | } 38 | 39 | private void onSuccess(NewsList newsList) { 40 | NewsListViewState.SUCCESS_STATE.setData(newsList); 41 | newsListState.postValue(NewsListViewState.SUCCESS_STATE); 42 | } 43 | 44 | private void onError(Throwable error) { 45 | NewsListViewState.ERROR_STATE.setError(error); 46 | newsListState.postValue(NewsListViewState.ERROR_STATE); 47 | } 48 | 49 | private void onLoading() { 50 | newsListState.postValue(NewsListViewState.LOADING_STATE); 51 | } 52 | 53 | @Override 54 | protected void onCleared() { 55 | super.onCleared(); 56 | if (disposable != null) { 57 | disposable.clear(); 58 | disposable = null; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/util/AppConstants.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.util; 2 | 3 | public final class AppConstants { 4 | 5 | public static final String TITLE = "title"; 6 | public static final String URL = "url"; 7 | public static final String SUMMARY = "summary"; 8 | public static final String IMAGEURL = "imageURL"; 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/util/MediaDeserializer.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.util; 2 | 3 | import com.google.gson.TypeAdapter; 4 | import com.google.gson.stream.JsonReader; 5 | import com.google.gson.stream.JsonToken; 6 | import com.google.gson.stream.JsonWriter; 7 | 8 | import java.io.IOException; 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | 12 | import news.agoda.com.sample.api.model.MediaEntity; 13 | 14 | public class MediaDeserializer extends TypeAdapter> { 15 | 16 | @Override 17 | public void write(JsonWriter out, List value) throws IOException { 18 | 19 | } 20 | 21 | @Override 22 | public List read(JsonReader reader) throws IOException { 23 | List mediaEntities = new ArrayList<>(); 24 | try { 25 | if (reader.peek() == JsonToken.BEGIN_ARRAY) { 26 | reader.beginArray(); 27 | while (reader.hasNext()) { 28 | mediaEntities.add(readMessage(reader)); 29 | } 30 | reader.endArray(); 31 | } else { 32 | reader.skipValue(); 33 | return mediaEntities; 34 | } 35 | } catch (Exception e) { 36 | e.printStackTrace(); 37 | } 38 | 39 | return mediaEntities; 40 | } 41 | 42 | 43 | private MediaEntity readMessage(JsonReader reader) throws IOException { 44 | 45 | String url = null; 46 | String format = null; 47 | int height = 0; 48 | int width = 0; 49 | String type = null; 50 | String subType = null; 51 | String caption = null; 52 | String copyright = null; 53 | 54 | reader.beginObject(); 55 | while (reader.hasNext()) { 56 | String name = reader.nextName(); 57 | switch (name) { 58 | case "url": 59 | url = reader.nextString(); 60 | break; 61 | case "format": 62 | format = reader.nextString(); 63 | break; 64 | case "height": 65 | height = reader.nextInt(); 66 | break; 67 | case "width": 68 | width = reader.nextInt(); 69 | break; 70 | case "type": 71 | type = reader.nextString(); 72 | break; 73 | case "subtype": 74 | subType = reader.nextString(); 75 | break; 76 | case "caption": 77 | caption = reader.nextString(); 78 | break; 79 | case "copyright": 80 | copyright = reader.nextString(); 81 | break; 82 | default: 83 | reader.skipValue(); 84 | break; 85 | } 86 | 87 | } 88 | reader.endObject(); 89 | return new MediaEntity(url, format, height, width, type, subType, caption, copyright); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/java/news/agoda/com/sample/util/Utilities.java: -------------------------------------------------------------------------------- 1 | package news.agoda.com.sample.util; 2 | 3 | import android.content.Context; 4 | import android.net.ConnectivityManager; 5 | import android.net.NetworkInfo; 6 | 7 | import io.reactivex.Observable; 8 | 9 | public final class Utilities { 10 | 11 | private Utilities() { 12 | } 13 | 14 | public static boolean isNetworkConnected(Context context) { 15 | ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); 16 | NetworkInfo info = cm.getActiveNetworkInfo(); 17 | return info != null && info.isConnected(); 18 | 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/place_holder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/droiddevgeeks/NewsApp/06861a8726aaa82d2657f022bab304f109b36df8/app/src/main/res/drawable/place_holder.png -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_detail.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 13 | 14 | 17 | 18 | 19 | 20 | 24 | 25 | 34 | 35 | 45 | 46 | 54 | 55 | 56 |