Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PR submission for the OTChallenge app #16

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 53 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,55 @@
# The Challenge:

The challenge is to create a simple Android app that exercises a REST-ful API. The API endpoint `https://api.nytimes.com/svc/books/v3/lists/current/hardcover-fiction.json?api-key=KoRB4K5LRHygfjCL2AH6iQ7NeUqDAGAB&offset=0` returns a JSON object which is a list of different books published by the New York Times.

Using this endpoint, show a list of these items, with each row displaying at least the following data:

- Image
- Title
- Description

### Technical Instructions:
- MVP architecture, no ViewModel
- XML Layouts (no Compose)
- Demonstrate use of Dagger, Retrofit and Glide (for the images)
- No database needed
- Feel free to make any assumptions you want along the way or use any third party libraries as needed and document why you used them.

### General Instructions:
- This isn't a visual design exercise. For example, if you set random background colors to clearly differentiate the views when debugging, pick Comic Sans or Papyrus, we won't hold that against you. Well, maybe a little bit if you use Comic Sans :)
- This is also most of the code you'll be showing us – don't understimate the difficulty of the task, or the importance of this exercise in our process, and rush your PR. Put up your best professional game.
- This isn't just about handling the happy path. Think slow network (or no network at all), supporting different device sizes, ease of build and run of the project. If we can't check out and click the run button in Android Studio, you're off to a bad start (we've had PRs without a graddle for instance).
- Explanations on any choice you've made are welcome.
- We appreciate there's a lot that is asked in this exercise. If you need more time, feel free to ask. If you need to de-prioritize something, apply the same judgement you would on a professional project, argument your decision.

Bonus Points:
- Unit tests
This is a simple Android app that exercises a REST-ful API and shows a list of different books published by the New York Times. The API endpoint `https://api.nytimes.com/svc/books/v3/lists/current/hardcover-fiction.json?api-key=KoRB4K5LRHygfjCL2AH6iQ7NeUqDAGAB&offset=0`.

## System Requirement:

- App tested with `Android Studio Koala Feature Drop | 2024.1.2 Patch 1`

## Important Dependencies

- Kotlin 2.0.20
- Various AndroidX libraries for core functionality
- Dagger for dependency injection
- Glide for image loading
- Retrofit for networking
- KotlinX Serialization for serializing data
- Paparazzi for snapshot testing
- Timber for logging

## App Screenshots

### Phone - Pixel 8a

| Portrait | Landscape |
|------------------------------|-----------------------------------|
| ![](screenshot_pixel_8a.png) | ![](screenshot_pixel_8a_land.png) |

### Tablet - Pixel Tablet

![](screenshot_pixel_tablet.png)

## Screenshot Testing

This app uses [Paparazzi](https://cashapp.github.io/paparazzi/) to perform snapshot testing.

Look at `BookListItemSnapshotTest.kt` for reference. It captures various snapshots that cover phone
UI, tablet UI, day and night mode, font scaling for accessibility etc.

To verify the screenshots, run from root folder
```shell
./gradlew verifyPaparazziDebug
```

To record new screenshots, run from root folder
```shell
./gradlew recordPaparazziDebug
```

## Command to format, test and build debug app

A custom Gradle task has been created to easy of use that would execute all important tasks for the build

```shell
./gradlew formatTestAssemble
```
65 changes: 49 additions & 16 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.kapt)
alias(libs.plugins.serialization)
alias(libs.plugins.paparazzi)
alias(libs.plugins.ksp)
alias(libs.plugins.spotless)
}

// TODO This is never a good idea to put API KEY here and push it to version control
// Use Google's secrets Gradle plugin https://github.com/google/secrets-gradle-plugin
// for better way to do this.
def apiKey = "KoRB4K5LRHygfjCL2AH6iQ7NeUqDAGAB"

android {
namespace 'com.example.otchallenge'
compileSdk 34
compileSdk 35

defaultConfig {
applicationId "com.example.otchallenge"
minSdk 24
targetSdk 34
targetSdk 35
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "NYTIMES_API_KEY", "\"${apiKey}\"")
}
testOptions {
unitTests {
includeAndroidResources = true
returnDefaultValues = true
}
}

buildTypes {
Expand All @@ -24,12 +38,16 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
buildConfig true
viewBinding true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '11'
}
}

Expand All @@ -38,25 +56,40 @@ dependencies {
implementation libs.androidx.core.ktx
implementation libs.androidx.appcompat
implementation libs.material
implementation libs.androidx.activity
implementation libs.androidx.activity.ktx
implementation libs.androidx.fragment.ktx
implementation libs.androidx.navigation.fragment.ktx
implementation libs.androidx.navigation.ui.ktx
implementation libs.androidx.constraintlayout

// dagger
implementation libs.dagger
kapt libs.dagger.compiler
implementation libs.androidx.recyclerview
implementation libs.androidx.espresso.idling.resource
ksp libs.dagger.compiler

//retrofit
// retrofit
implementation libs.retrofit
implementation libs.retrofit.rx.adapter
implementation libs.retrofit.kotlinx.serializatoin
implementation libs.okhttp
implementation libs.okttp.logging

//glide
// glide
implementation libs.glide

//reactive x
implementation libs.rx.android
implementation libs.rx.java
implementation libs.rx.kotlin
// coroutines
implementation libs.kotlinx.coroutines

// serialization
implementation libs.kotlinx.serialization

// logging
implementation libs.timber

testImplementation libs.junit
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.mockito.kotlin
androidTestImplementation libs.fragment.testing
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.example.otchallenge

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasMinimumChildCount
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.otchallenge.ui.booklist.CountingIdlingResourceProvider
import org.hamcrest.CoreMatchers.not
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class BookListFragmentTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)

@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance().register(CountingIdlingResourceProvider.countingIdlingResource)
}

@After
fun unregisterIdlingResource() {
IdlingRegistry.getInstance().unregister(CountingIdlingResourceProvider.countingIdlingResource)
}

@Test
fun testInitialDataLoading() {
onView(withId(R.id.list))
.check(matches(isDisplayed()))
.check(matches(hasMinimumChildCount(1)))

onView(withId(R.id.loading)).check(matches(not(isDisplayed())))
}
}

This file was deleted.

43 changes: 23 additions & 20 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools">

<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidOTChallenge"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<uses-permission android:name="android.permission.INTERNET" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidOTChallenge"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
50 changes: 39 additions & 11 deletions app/src/main/java/com/example/otchallenge/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,46 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupActionBarWithNavController
import com.example.otchallenge.databinding.ActivityMainBinding
import com.example.otchallenge.util.logDebug

class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "MainActivity"
}

override fun onCreate(savedInstanceState: Bundle?) {
(application as MyApplication).appComponent.inject(this)
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
logDebug("$TAG - onCreate")
(application as MyApplication).appComponent.inject(this)
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
WindowInsetsCompat.CONSUMED
}
setupActionBarNavController()
}

override fun onDestroy() {
super.onDestroy()
logDebug("$TAG - onDestroy")
}

private fun setupActionBarNavController() {
setSupportActionBar(binding.toolbar)
val navController =
binding.content.navHostContainer
.getFragment<NavHostFragment>()
.navController
val appBarConfiguration = AppBarConfiguration(navController.graph)
setupActionBarWithNavController(navController, appBarConfiguration)
}
}
16 changes: 10 additions & 6 deletions app/src/main/java/com/example/otchallenge/MyApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ package com.example.otchallenge
import android.app.Application
import com.example.otchallenge.di.AppComponent
import com.example.otchallenge.di.DaggerAppComponent
import timber.log.Timber
import timber.log.Timber.DebugTree

class MyApplication : Application() {
lateinit var appComponent: AppComponent

lateinit var appComponent: AppComponent

override fun onCreate() {
super.onCreate()
appComponent = DaggerAppComponent.builder().build()
}
override fun onCreate() {
super.onCreate()
appComponent = DaggerAppComponent.builder().build()
if (BuildConfig.DEBUG) {
Timber.plant(DebugTree())
}
}
}
Loading