WIP: feature/abstractions (#45)

* Abstraction layer backup

* Removed DataEntity, was unnecessary for now

* Separated network, persistence, entities and interaction, closes #29

* Renamed binding

* Removed build files, example tests

Removed build files, example tests

* Fixed build files were not being ignored all around app

* Updated CI ymls

* Small changes

* Fixed legacy repository package names

* Fixed CQ findings

* Updated Fastlane

* Packaging changes and version upgrades

* Removed core from interactors

* Version bumps

* Added new module graph
This commit is contained in:
Melih Aksoy
2019-10-30 17:27:53 +01:00
committed by GitHub
parent 83e39400a9
commit 88022629e1
103 changed files with 1098 additions and 921 deletions

View File

@@ -76,12 +76,60 @@ jobs:
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: features/detail/build/test-results
## Repository
## Abstractions
- run:
name: Test Repository
name: Test Abstractions
command: |
fastlane test_repository
./gradlew repository:jacocoTestReport
fastlane test_abstractions
./gradlew abstractions:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: repository/build/test-results
## Definitions
- run:
name: Test Definitions
command: |
fastlane test_definitions
./gradlew data:definitions:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: repository/build/test-results
## Interactors
- run:
name: Test Interactors
command: |
fastlane test_interactors
./gradlew data:interactors:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: repository/build/test-results
## Network
- run:
name: Test Network
command: |
fastlane test_network
./gradlew data:network:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: repository/build/test-results
## Persistence
- run:
name: Test Persistence
command: |
fastlane test_persistence
./gradlew data:persistence:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests

View File

@@ -55,8 +55,32 @@ jobs:
./gradlew features:detail:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Repository
- name: Test Abstractions
run: |
fastlane test_repository
./gradlew repository:jacocoTestReport
fastlane test_abstractions
./gradlew abstractions:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Definitions
run: |
fastlane test_definitions
./gradlew data:definitions:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Interactors
run: |
fastlane test_interactors
./gradlew data:interactors:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Network
run: |
fastlane test_network
./gradlew data:network:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Persistence
run: |
fastlane test_persistence
./gradlew data:persistence:jacocoTestReport
bash <(curl -s https://codecov.io/bash)

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
/.idea
.DS_Store
/build
**/build
/fastlane/README.md
/fastlane/report.xml
/captures

View File

@@ -5,7 +5,7 @@ GEM
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
atomos (0.1.3)
babosa (1.0.2)
babosa (1.0.3)
claide (1.0.3)
colored (1.2)
colored2 (3.1.2)
@@ -18,7 +18,7 @@ GEM
unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.5)
emoji_regex (1.0.1)
excon (0.66.0)
excon (0.67.0)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
faraday-cookie_jar (0.0.6)
@@ -27,7 +27,7 @@ GEM
faraday_middleware (0.13.1)
faraday (>= 0.7.4, < 1.0)
fastimage (2.1.7)
fastlane (2.131.0)
fastlane (2.133.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.3, < 3.0.0)
babosa (>= 1.0.2, < 2.0.0)
@@ -37,9 +37,9 @@ GEM
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 2.0)
excon (>= 0.45.0, < 1.0.0)
faraday (~> 0.9)
faraday (< 0.16.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 0.9)
faraday_middleware (< 0.16.0)
fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-api-client (>= 0.21.2, < 0.24.0)
@@ -52,7 +52,7 @@ GEM
multipart-post (~> 2.0.0)
plist (>= 3.1.0, < 4.0.0)
public_suffix (~> 2.0.0)
rubyzip (>= 1.2.2, < 2.0.0)
rubyzip (>= 1.3.0, < 2.0.0)
security (= 0.1.3)
simctl (~> 1.6.3)
slack-notifier (>= 2.0.0, < 3.0.0)
@@ -98,7 +98,7 @@ GEM
memoist (0.16.0)
mime-types (3.3)
mime-types-data (~> 3.2015)
mime-types-data (3.2019.0904)
mime-types-data (3.2019.1009)
mini_magick (4.9.5)
multi_json (1.13.1)
multi_xml (0.6.0)
@@ -114,7 +114,7 @@ GEM
uber (< 0.2.0)
retriable (3.1.2)
rouge (2.0.7)
rubyzip (1.2.4)
rubyzip (1.3.0)
security (0.1.3)
signet (0.11.0)
addressable (~> 2.3)

17
abstractions/build.gradle Normal file
View File

@@ -0,0 +1,17 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: "de.mannodermaus.android-junit5"
apply from: "$rootProject.projectDir/scripts/module.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation libraries.kotlin
testImplementation testLibraries.jUnitApi
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testRuntimeOnly testLibraries.jUnitEngine
}

21
abstractions/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.abstractions" />

View File

@@ -0,0 +1,3 @@
package com.melih.abstractions.data
interface ViewEntity

View File

@@ -0,0 +1,6 @@
package com.melih.abstractions.deliverable
abstract class Reason : Throwable() {
abstract val messageRes: Int
}

View File

@@ -1,12 +1,9 @@
package com.melih.repository.interactors.base
import kotlinx.coroutines.ExperimentalCoroutinesApi
package com.melih.abstractions.deliverable
/**
* Result class that wraps any [Success], [Failure] or [State] that can be generated by any derivation of [BaseInteractor]
* Result class that wraps any [Success], [Failure] or [State]
*/
@UseExperimental(ExperimentalCoroutinesApi::class)
sealed class Result<out T>
//region Subclasses
@@ -22,7 +19,11 @@ sealed class State : Result<Nothing>() {
//region Extensions
inline fun <T> Result<T>.handle(stateBlock: (State) -> Unit, failureBlock: (Reason) -> Unit, successBlock: (T) -> Unit) {
inline fun <T> Result<T>.handle(
stateBlock: (State) -> Unit,
failureBlock: (Reason) -> Unit,
successBlock: (T) -> Unit
) {
when (this) {
is Success -> successBlock(successData)
is Failure -> failureBlock(errorData)

View File

@@ -0,0 +1,8 @@
package com.melih.abstractions.mapper
import com.melih.abstractions.data.ViewEntity
abstract class Mapper<in T, out R : ViewEntity> {
abstract fun convert(t: T): R
}

View File

@@ -1,6 +1,10 @@
package com.melih.repository.interactors.base
package com.melih.abstractions
import com.melih.repository.R
import com.melih.abstractions.deliverable.Failure
import com.melih.abstractions.deliverable.Reason
import com.melih.abstractions.deliverable.State
import com.melih.abstractions.deliverable.Success
import com.melih.abstractions.deliverable.handle
import io.mockk.called
import io.mockk.spyk
import io.mockk.verify
@@ -8,12 +12,17 @@ import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test
class ResultTest {
private val number = 10
private val success = Success(number)
private val failure = Failure(GenericError())
private val failure = Failure(object : Reason() {
override val messageRes: Int
get() = 10
})
private val state = State.Loading()
private val emptyStateBlock = spyk({ _: State -> })
@@ -37,8 +46,7 @@ class ResultTest {
@Test
fun `Failure should only invoke failureBlock with correct error`() {
val actualFailureBlock = spyk({ reason: Reason ->
reason shouldBeInstanceOf GenericError::class
(reason as GenericError).messageRes shouldEqualTo R.string.reason_generic
reason.messageRes shouldEqualTo 10
Unit
})

View File

@@ -3,6 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/default_android_config.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
apply from: "$rootProject.projectDir/scripts/sources.gradle"
android {
@@ -31,10 +32,14 @@ dependencies {
androidTestImplementation testLibraries.espresso
// These libraries required by dagger to create dependency graph, but not by app
compileOnly project(':repository')
// These libraries required by dagger to create dependency graph and application compilation, but not used by app
compileOnly project(':abstractions')
compileOnly project(':data:interactors')
compileOnly project(':data:network')
compileOnly project(':data:definitions')
compileOnly libraries.retrofit
compileOnly libraries.room
compileOnly libraries.paging
compileOnly libraries.swipeRefreshLayout

View File

@@ -21,8 +21,5 @@
@com.squareup.moshi.ToJson <methods>;
}
-keepnames @kotlin.Metadata class com.myapp.packagename.model.**
-keep class com.myapp.packagnename.model.** { *; }
# Keeping entities intact
-keep class com.melih.repository.entities.** { *; }
-keep class com.melih.definitions.entities.** { *; }

View File

@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.3.50'
ext.nav_version = '2.2.0-alpha01'
ext.nav_version = '2.2.0-beta01'
repositories {
google()
@@ -9,7 +9,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0'
classpath 'com.android.tools.build:gradle:3.5.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.18"
classpath "de.mannodermaus.gradle.plugins:android-junit5:1.5.1.0"
@@ -20,9 +20,9 @@ buildscript {
}
plugins {
id "io.gitlab.arturbosch.detekt" version "1.0.0"
id "org.jetbrains.dokka" version "0.9.18"
id "jacoco"
id 'io.gitlab.arturbosch.detekt' version '1.1.1'
id 'org.jetbrains.dokka' version '0.9.18'
id 'jacoco'
}
allprojects {
@@ -97,7 +97,9 @@ task projectDependencyGraph {
rootProjects.remove(dependency)
def graphKey = new Tuple2<Project, Project>(project, dependency)
def traits = dependencies.computeIfAbsent(graphKey) { new ArrayList<String>() }
def traits = dependencies.computeIfAbsent(graphKey) {
new ArrayList<String>()
}
if (config.name.toLowerCase().endsWith('implementation')) {
traits.add('style=dotted')

View File

@@ -3,6 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/default_android_config.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
apply from: "$rootProject.projectDir/scripts/sources.gradle"
android {
@@ -14,8 +15,7 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':repository')
implementation libraries.coroutines
implementation libraries.fragment
implementation libraries.paging
implementation libraries.lifecycle

View File

@@ -9,8 +9,8 @@ import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import com.melih.abstractions.deliverable.Reason
import com.melih.core.R
import com.melih.repository.interactors.base.Reason
/**
* Parent of all fragments.

View File

@@ -4,12 +4,13 @@ import androidx.annotation.CallSuper
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.State
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onState
import com.melih.repository.interactors.base.onSuccess
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Reason
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.State
import com.melih.abstractions.deliverable.onFailure
import com.melih.abstractions.deliverable.onState
import com.melih.abstractions.deliverable.onSuccess
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -35,11 +36,11 @@ const val INITIAL_PAGE = 0
*/
@UseExperimental(ExperimentalCoroutinesApi::class)
abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() {
abstract class BasePagingDataSource<R : ViewEntity> : PageKeyedDataSource<Int, R>() {
//region Abstractions
abstract fun loadDataForPage(page: Int): Flow<Result<List<T>>> // Load next page(s)
abstract fun loadDataForPage(page: Int): Flow<Result<List<R>>> // Load next page(s)
//endregion
//region Properties
@@ -63,7 +64,10 @@ abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() {
//region Functions
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, T>) {
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Int, R>
) {
// Looping through channel as we'll receive any state, error or data here
loadDataForPage(INITIAL_PAGE)
.onEach { result ->
@@ -81,7 +85,7 @@ abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() {
.launchIn(coroutineScope)
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, T>) {
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, R>) {
// Key for which page to load is in params
val page = params.key
@@ -104,7 +108,7 @@ abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() {
/**
* This loads previous pages, we don't have a use for it yet, so it's a no-op override
*/
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, T>) {
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, R>) {
// no-op
}

View File

@@ -3,6 +3,7 @@ package com.melih.core.base.paging
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import com.melih.abstractions.data.ViewEntity
/**
* Base [factory][DataSource.Factory] class for any [dataSource][DataSource]s in project.
@@ -14,7 +15,7 @@ import androidx.paging.DataSource
*
* Purpose of this transmission is to encapuslate [basePagingDataSource][BasePagingDataSource].
*/
abstract class BasePagingFactory<T> : DataSource.Factory<Int, T>() {
abstract class BasePagingFactory<T : ViewEntity> : DataSource.Factory<Int, T>() {
//region Abstractions

View File

@@ -5,9 +5,10 @@ import androidx.lifecycle.Transformations.switchMap
import androidx.lifecycle.ViewModel
import androidx.paging.PagedList
import androidx.paging.toLiveData
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Reason
import com.melih.abstractions.deliverable.State
import com.melih.core.base.paging.BasePagingFactory
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.State
/**
* Base [ViewModel] for view models that will use [PagedList].
@@ -18,7 +19,7 @@ import com.melih.repository.interactors.base.State
*
* If paging won't be used, use [BaseViewModel] instead.
*/
abstract class BasePagingViewModel<T> : ViewModel() {
abstract class BasePagingViewModel<T : ViewEntity> : ViewModel() {
//region Abstractions

View File

@@ -4,8 +4,8 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.State
import com.melih.abstractions.deliverable.Reason
import com.melih.abstractions.deliverable.State
import kotlinx.coroutines.launch
/**

View File

@@ -1,32 +0,0 @@
package com.melih.core.extensions
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import com.melih.core.utils.ClearFocusQueryTextListener
/**
* Shorthand for [contains] with ignoreCase set [true]
*/
fun CharSequence.containsIgnoreCase(other: CharSequence) = contains(other, true)
/**
* Adds [ClearFocusQueryTextListener] as [SearchView.OnQueryTextListener]
*/
fun SearchView.setOnQueryChangedListener(block: (String?) -> Unit) = setOnQueryTextListener(ClearFocusQueryTextListener(this, block))
/**
* Shortening set menu item expands / collapses
*/
fun MenuItem.onExpandOrCollapse(onExpand: () -> Unit, onCollapse: () -> Unit) {
setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
onCollapse()
return true
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
onExpand()
return true
}
})
}

View File

@@ -3,14 +3,15 @@
package com.melih.core.paging
import androidx.paging.PageKeyedDataSource
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Failure
import com.melih.abstractions.deliverable.Reason
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.State
import com.melih.abstractions.deliverable.Success
import com.melih.core.BaseTestWithMainThread
import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.testObserve
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.GenericError
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.State
import com.melih.repository.interactors.base.Success
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
@@ -28,7 +29,7 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
val failureSource = spyk(TestFailureSource())
val data = 10
val errorMessage = "Generic Error"
val errorMessageResId = 1313
@Nested
inner class BasePagingSource {
@@ -37,10 +38,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
inner class LoadInitial {
@Test
fun `should update state accordingly`() {
val params = mockk<PageKeyedDataSource.LoadInitialParams<Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, TestViewEntity>>(relaxed = true)
runBlocking {
@@ -54,10 +54,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
}
@Test
fun `should update error Error accordingly`() {
val params = PageKeyedDataSource.LoadInitialParams<Int>(10, false)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, TestViewEntity>>(relaxed = true)
runBlocking {
@@ -65,7 +64,7 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
failureSource.loadInitial(params, callback)
failureSource.reasonData.testObserve {
it shouldBeInstanceOf GenericError::class
it shouldBeInstanceOf TestFailureReason::class
}
}
}
@@ -75,10 +74,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
inner class LoadAfter {
@Test
fun `should update state accordingly`() {
val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, TestViewEntity>>(relaxed = true)
runBlocking {
@@ -92,10 +90,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
}
@Test
fun `should update error Error accordingly`() {
val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, TestViewEntity>>(relaxed = true)
runBlocking {
@@ -103,17 +100,16 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
failureSource.loadAfter(params, callback)
failureSource.reasonData.testObserve {
it shouldBeInstanceOf GenericError::class
it shouldBeInstanceOf TestFailureReason::class
}
}
}
}
@Test
fun `should use loadDataForPage in loadInitial and transform emmited value`() {
val params = mockk<PageKeyedDataSource.LoadInitialParams<Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, TestViewEntity>>(relaxed = true)
// Fake loading
source.loadInitial(params, callback)
@@ -126,10 +122,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
}
@Test
fun `should use loadDataForPage in loadAfter and transform emmited value`() {
val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, TestViewEntity>>(relaxed = true)
// Fake loading
source.loadAfter(params, callback)
@@ -142,25 +137,27 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
}
}
inner class TestSource : BasePagingDataSource<Int>() {
inner class TestSource : BasePagingDataSource<TestViewEntity>() {
val result = flow {
emit(State.Loading())
emit(Success(listOf(data)))
emit(Success(listOf(TestViewEntity(data))))
}
override fun loadDataForPage(page: Int): Flow<Result<List<Int>>> = result
override fun loadDataForPage(page: Int): Flow<Result<List<TestViewEntity>>> = result
}
inner class TestFailureSource : BasePagingDataSource<Int>() {
inner class TestFailureSource : BasePagingDataSource<TestViewEntity>() {
val result = flow {
emit(State.Loading())
emit(Failure(GenericError()))
emit(Failure(TestFailureReason(errorMessageResId)))
}
override fun loadDataForPage(page: Int): Flow<Result<List<Int>>> = result
override fun loadDataForPage(page: Int): Flow<Result<List<TestViewEntity>>> = result
}
inner class TestViewEntity(data: Int) : ViewEntity
inner class TestFailureReason(override val messageRes: Int) : Reason()
}

View File

@@ -1,5 +1,6 @@
package com.melih.core.paging
import com.melih.abstractions.data.ViewEntity
import com.melih.core.BaseTestWithMainThread
import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.base.paging.BasePagingFactory
@@ -25,9 +26,10 @@ class BasePagingFactoryTest : BaseTestWithMainThread() {
}
}
inner class TestFactory : BasePagingFactory<String>() {
override fun createSource(): BasePagingDataSource<String> = mockk(relaxed = true)
inner class TestFactory : BasePagingFactory<TestViewEntity>() {
override fun createSource(): BasePagingDataSource<TestViewEntity> = mockk(relaxed = true)
}
inner class TestViewEntity : ViewEntity
}

View File

@@ -0,0 +1,17 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':abstractions')
implementation libraries.room
implementation libraries.moshiKotlin
kapt annotationProcessors.roomCompiler
kapt annotationProcessors.moshi
}

View File

21
data/definitions/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.definitions.entities" />

View File

@@ -1,4 +1,4 @@
package com.melih.repository
package com.melih.definitions
internal const val DEFAULT_NAME = "Default name"
internal const val EMPTY_STRING = ""

View File

@@ -0,0 +1,23 @@
package com.melih.definitions
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
/**
* Contract for sources to seperate business logic from build and return type
*/
interface Source {
//region Abstractions
suspend fun <T : ViewEntity> getNextLaunches(
count: Int,
page: Int,
mapper: Mapper<LaunchEntity, T>
): Result<List<T>>
suspend fun <T : ViewEntity> getLaunchById(id: Long, mapper: Mapper<LaunchEntity, T>): Result<T>
//endregion
}

View File

@@ -1,8 +1,8 @@
package com.melih.repository.entities
package com.melih.definitions.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.melih.repository.DEFAULT_NAME
import com.melih.definitions.DEFAULT_NAME
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View File

@@ -1,4 +1,4 @@
package com.melih.repository.entities
package com.melih.definitions.entities
import com.squareup.moshi.JsonClass

View File

@@ -1,7 +1,7 @@
package com.melih.repository.entities
package com.melih.definitions.entities
import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME
import com.melih.definitions.DEFAULT_NAME
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)

View File

@@ -1,8 +1,8 @@
package com.melih.repository.entities
package com.melih.definitions.entities
import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING
import com.melih.definitions.DEFAULT_NAME
import com.melih.definitions.EMPTY_STRING
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)

View File

@@ -1,8 +1,8 @@
package com.melih.repository.entities
package com.melih.definitions.entities
import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING
import com.melih.definitions.DEFAULT_NAME
import com.melih.definitions.EMPTY_STRING
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View File

@@ -0,0 +1,22 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':data:definitions')
implementation project(':data:network')
implementation project(':data:persistence')
implementation libraries.coroutines
implementation libraries.retrofit
testImplementation testLibraries.coroutinesCore
testImplementation testLibraries.coroutinesTest
compileOnly libraries.room
}

View File

21
data/interactors/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.interactors" />

View File

@@ -0,0 +1,40 @@
package com.melih.interactors
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
import com.melih.interactors.base.BaseInteractor
import com.melih.interactors.base.InteractorParameters
import com.melih.interactors.sources.LaunchesSource
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
/**
* Gets next given number of launches
*/
class GetLaunchDetails<T : ViewEntity> @Inject constructor(
private val mapper: @JvmSuppressWildcards Mapper<LaunchEntity, T>
) : BaseInteractor<T, GetLaunchDetails.Params>() {
//region Properties
@Inject
internal lateinit var launchesSource: LaunchesSource
//endregion
//region Functions
override suspend fun FlowCollector<Result<T>>.run(params: Params) {
emit(launchesSource.getLaunchById(params.id, mapper))
}
//endregion
//region Parameters
data class Params(
val id: Long
) : InteractorParameters
//endregion
}

View File

@@ -0,0 +1,44 @@
package com.melih.interactors
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
import com.melih.interactors.base.BaseInteractor
import com.melih.interactors.base.InteractorParameters
import com.melih.interactors.sources.LaunchesSource
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
const val DEFAULT_LAUNCHES_AMOUNT = 15
/**
* Gets next given number of launches
*/
class GetLaunches<T : ViewEntity> @Inject constructor(
private val mapper: @JvmSuppressWildcards Mapper<LaunchEntity, T>
) : BaseInteractor<List<T>, GetLaunches.Params>() {
//region Properties
@Inject
internal lateinit var launchesSource: LaunchesSource
//endregion
//region Functions
override suspend fun FlowCollector<Result<List<T>>>.run(params: Params) {
// Start network fetch - we're not handling state here to ommit them
emit(
launchesSource
.getNextLaunches(params.count, params.page, mapper)
)
}
//endregion
data class Params(
val count: Int = DEFAULT_LAUNCHES_AMOUNT,
val page: Int
) : InteractorParameters
}

View File

@@ -1,5 +1,7 @@
package com.melih.repository.interactors.base
package com.melih.interactors.base
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.State
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow

View File

@@ -0,0 +1,17 @@
package com.melih.interactors.error
import androidx.annotation.StringRes
import com.melih.abstractions.deliverable.Reason
import com.melih.interactors.R
sealed class InteractionErrorReason(@StringRes override val messageRes: Int) : Reason()
class GenericError(@StringRes override val messageRes: Int = R.string.reason_generic) : InteractionErrorReason(messageRes)
sealed class NetworkError(override val messageRes: Int) : InteractionErrorReason(messageRes)
class ConnectionError : NetworkError(R.string.reason_network)
class EmptyResultError : NetworkError(R.string.reason_empty_body)
class ResponseError : NetworkError(R.string.reason_response)
class TimeoutError : NetworkError(R.string.reason_timeout)
class PersistenceEmptyError : InteractionErrorReason(R.string.reason_persistance_empty)

View File

@@ -0,0 +1,163 @@
package com.melih.interactors.sources
import android.content.Context
import android.net.NetworkInfo
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Failure
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.Success
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.Source
import com.melih.definitions.entities.LaunchEntity
import com.melih.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.interactors.error.ConnectionError
import com.melih.interactors.error.EmptyResultError
import com.melih.interactors.error.NetworkError
import com.melih.interactors.error.PersistenceEmptyError
import com.melih.interactors.error.ResponseError
import com.melih.interactors.error.TimeoutError
import com.melih.network.ApiImpl
import com.melih.persistence.LaunchesDatabase
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Provider
private const val DEFAULT_IMAGE_SIZE = 480
internal class LaunchesSource @Inject constructor(
ctx: Context,
private val apiImpl: ApiImpl,
private val networkInfoProvider: Provider<NetworkInfo>
) : Source {
//region Properties
private val launchesDatabase = LaunchesDatabase.getInstance(ctx)
private val isNetworkConnected: Boolean
get() {
val networkInfo = networkInfoProvider.get()
return networkInfo != null && networkInfo.isConnected
}
//endregion
//region Functions
override suspend fun <T : ViewEntity> getNextLaunches(
count: Int,
page: Int, mapper: Mapper<LaunchEntity, T>
): Result<List<T>> {
val networkResponse = safeExecute({
apiImpl.getNextLaunches(count, page * DEFAULT_LAUNCHES_AMOUNT)
}) { entity ->
entity.launches
.map(::transformRocketImageUrl)
.saveLaunches()
.map(mapper::convert)
}
return if (networkResponse is NetworkError) {
launchesDatabase
.launchesDao
.getLaunches(count, page)
.takeUnless { it.isNullOrEmpty() }
?.run {
Success(map(mapper::convert))
} ?: Failure(PersistenceEmptyError())
} else {
networkResponse
}
}
override suspend fun <T : ViewEntity> getLaunchById(
id: Long,
mapper: Mapper<LaunchEntity, T>
): Result<T> {
return launchesDatabase
.launchesDao
.getLaunchById(id)
.takeIf { it != null }
?.run {
Success(mapper.convert(this))
} ?: loadLaunchFromNetwork(id, mapper)
}
private suspend fun <T : ViewEntity> loadLaunchFromNetwork(
id: Long,
mapper: Mapper<LaunchEntity, T>
): Result<T> =
safeExecute({
apiImpl.getLaunchById(id)
}) {
mapper.convert(
transformRocketImageUrl(it)
.saveLaunch()
)
}
private suspend fun List<LaunchEntity>.saveLaunches() = run {
launchesDatabase.launchesDao.saveLaunches(this)
this
}
private suspend fun LaunchEntity.saveLaunch() = run {
launchesDatabase.launchesDao.saveLaunch(this)
this
}
private inline fun <T, R> safeExecute(
block: () -> Response<T>,
transform: (T) -> R
) =
if (isNetworkConnected) {
try {
block().extractResponseBody(transform)
} catch (e: IOException) {
Failure(TimeoutError())
}
} else {
Failure(ConnectionError())
}
private inline fun <T, R> Response<T>.extractResponseBody(transform: (T) -> R) =
if (isSuccessful) {
body()?.let {
Success(transform(it))
} ?: Failure(EmptyResultError())
} else {
Failure(ResponseError())
}
private fun transformRocketImageUrl(launch: LaunchEntity) =
if (!launch.rocket.imageURL.isNotBlank()) {
launch.copy(
rocket = launch.rocket.copy(
imageURL = transformImageUrl(
launch.rocket.imageURL,
launch.rocket.imageSizes
)
)
)
} else {
launch
}
private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) =
try {
val urlSplit = imageUrl.split("_")
val url = urlSplit[0]
val format = urlSplit[1].split(".")[1]
val requestedSize = if (!supportedSizes.contains(DEFAULT_IMAGE_SIZE)) {
supportedSizes.last { it < DEFAULT_IMAGE_SIZE }
} else {
DEFAULT_IMAGE_SIZE
}
"${url}_$requestedSize.$format"
} catch (e: Exception) {
imageUrl
}
//endregion
}

View File

@@ -1,9 +1,8 @@
<resources>
<string name="reason_generic">Something went wrong</string>
<string name="reason_persistance_empty">There are no saved launches</string>
<string name="reason_network">Network error</string>
<string name="reason_empty_body">Response is empty</string>
<string name="reason_generic">Something went wrong</string>
<string name="reason_response">Woops, seems we got a server error</string>
<string name="reason_timeout">Server timed out</string>
<string name="reason_persistance_empty">There are no saved launches</string>
<string name="reason_no_network_persistance_empty">Seems there are no data and network</string>
</resources>

View File

@@ -1,5 +1,8 @@
package com.melih.repository.interactors.base
package com.melih.interactors.base
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.State
import com.melih.abstractions.deliverable.Success
import io.mockk.coVerify
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -10,7 +13,7 @@ import kotlinx.coroutines.runBlocking
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test
import java.util.*
import java.util.ArrayDeque
@UseExperimental(ExperimentalCoroutinesApi::class)
class BaseInteractorTest {

20
data/network/build.gradle Normal file
View File

@@ -0,0 +1,20 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':data:definitions')
implementation libraries.okHttpLogger
implementation libraries.moshiKotlin
implementation libraries.coroutines
implementation libraries.retrofit
testImplementation testLibraries.coroutinesCore
testImplementation testLibraries.coroutinesTest
}

View File

21
data/network/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.network" />

View File

@@ -1,7 +1,7 @@
package com.melih.repository.network
package com.melih.network
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity
import com.melih.definitions.entities.LaunchEntity
import com.melih.definitions.entities.LaunchesEntity
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path

View File

@@ -1,7 +1,7 @@
package com.melih.repository.network
package com.melih.network
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity
import com.melih.definitions.entities.LaunchEntity
import com.melih.definitions.entities.LaunchesEntity
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
@@ -14,7 +14,7 @@ import javax.inject.Inject
internal const val TIMEOUT_DURATION = 7L
internal class ApiImpl @Inject constructor() : Api {
class ApiImpl @Inject constructor() : Api {
//region Properties

View File

@@ -3,6 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
android {
defaultConfig {
@@ -17,15 +18,13 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation libraries.coroutines
implementation libraries.liveDataKTX
implementation libraries.retrofit
implementation libraries.room
implementation project(':data:definitions')
implementation libraries.moshiKotlin
implementation libraries.okHttpLogger
implementation libraries.coroutines
implementation libraries.room
kapt annotationProcessors.roomCompiler
kapt annotationProcessors.moshi
testImplementation testLibraries.coroutinesCore
testImplementation testLibraries.coroutinesTest

View File

21
data/persistence/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.persistence" />

View File

@@ -1,15 +1,15 @@
package com.melih.repository.persistence
package com.melih.persistence
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.persistence.converters.LocationConverter
import com.melih.repository.persistence.converters.MissionConverter
import com.melih.repository.persistence.converters.RocketConverter
import com.melih.repository.persistence.dao.LaunchesDao
import com.melih.definitions.entities.LaunchEntity
import com.melih.persistence.converters.LocationConverter
import com.melih.persistence.converters.MissionConverter
import com.melih.persistence.converters.RocketConverter
import com.melih.persistence.dao.LaunchesDao
const val DB_NAME = "LaunchesDB"
@@ -26,7 +26,7 @@ const val DB_NAME = "LaunchesDB"
RocketConverter::class,
MissionConverter::class
)
internal abstract class LaunchesDatabase : RoomDatabase() {
abstract class LaunchesDatabase : RoomDatabase() {
//region Companion
@@ -48,6 +48,6 @@ internal abstract class LaunchesDatabase : RoomDatabase() {
//region Abstractions
internal abstract val launchesDao: LaunchesDao
abstract val launchesDao: LaunchesDao
//endregion
}

View File

@@ -1,4 +1,4 @@
package com.melih.repository.persistence.converters
package com.melih.persistence.converters
import androidx.room.TypeConverter
import com.squareup.moshi.JsonAdapter

View File

@@ -1,4 +1,4 @@
package com.melih.repository.persistence.converters
package com.melih.persistence.converters
import androidx.room.TypeConverter
import com.squareup.moshi.JsonAdapter

View File

@@ -1,7 +1,7 @@
package com.melih.repository.persistence.converters
package com.melih.persistence.converters
import com.melih.repository.entities.LocationEntity
import com.melih.repository.entities.LocationEntityJsonAdapter
import com.melih.definitions.entities.LocationEntity
import com.melih.definitions.entities.LocationEntityJsonAdapter
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi

View File

@@ -1,6 +1,6 @@
package com.melih.repository.persistence.converters
package com.melih.persistence.converters
import com.melih.repository.entities.MissionEntity
import com.melih.definitions.entities.MissionEntity
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types

View File

@@ -1,7 +1,7 @@
package com.melih.repository.persistence.converters
package com.melih.persistence.converters
import com.melih.repository.entities.RocketEntity
import com.melih.repository.entities.RocketEntityJsonAdapter
import com.melih.definitions.entities.RocketEntity
import com.melih.definitions.entities.RocketEntityJsonAdapter
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi

View File

@@ -1,16 +1,16 @@
package com.melih.repository.persistence.dao
package com.melih.persistence.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.melih.repository.entities.LaunchEntity
import com.melih.definitions.entities.LaunchEntity
/**
* DAO for list of [launches][LaunchEntity]
*/
@Dao
internal abstract class LaunchesDao {
abstract class LaunchesDao {
//region Queries

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -50,9 +50,29 @@ platform :android do
run_detail_tests()
end
desc "Runs tests in repository module"
lane :test_repository do
run_repository_tests()
desc "Runs tests in abstraction module"
lane :test_abstractions do
run_abstractions_tests()
end
desc "Runs tests in definitions module"
lane :test_definitions do
run_definitions_tests()
end
desc "Runs tests in interactors module"
lane :test_interactors do
run_interactors_tests()
end
desc "Runs tests in network module"
lane :test_network do
run_network_tests()
end
desc "Runs tests in persistence module"
lane :test_persistence do
run_persistence_tests()
end
# ================ Gradle tasks ================
@@ -81,7 +101,23 @@ platform :android do
gradle(task: "features:detail:test --continue")
end
def run_repository_tests
gradle(task: "repository:test --continue")
def run_abstractions_tests
gradle(task: "abstractions:test --continue")
end
def run_definitions_tests
gradle(task: "data:definitions:test --continue")
end
def run_interactors_tests
gradle(task: "data:interactors:test --continue")
end
def run_network_tests
gradle(task: "data:network:test --continue")
end
def run_persistence_tests
gradle(task: "data:persistence:test --continue")
end
end

View File

@@ -14,7 +14,5 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':repository')
testImplementation testLibraries.coroutinesTest
}

View File

@@ -0,0 +1,10 @@
package com.melih.launches.data
import com.melih.abstractions.data.ViewEntity
data class LaunchDetailItem(
val id: Long,
val imageUrl: String,
val rocketName: String,
val missionDescription: String
) : ViewEntity

View File

@@ -0,0 +1,18 @@
package com.melih.launches.data
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
import javax.inject.Inject
class LaunchDetailMapper @Inject constructor() : Mapper<LaunchEntity, LaunchDetailItem>() {
override fun convert(launchEntity: LaunchEntity) =
with(launchEntity) {
LaunchDetailItem(
id,
rocket.imageURL,
rocket.name,
if (!missions.isNullOrEmpty()) missions[0].description else ""
)
}
}

View File

@@ -2,11 +2,15 @@ package com.melih.detail.di.modules
import androidx.lifecycle.ViewModel
import androidx.navigation.fragment.navArgs
import com.melih.abstractions.mapper.Mapper
import com.melih.core.di.keys.ViewModelKey
import com.melih.definitions.entities.LaunchEntity
import com.melih.detail.ui.DetailFragment
import com.melih.detail.ui.DetailFragmentArgs
import com.melih.detail.ui.DetailViewModel
import com.melih.repository.interactors.GetLaunchDetails
import com.melih.interactors.GetLaunchDetails
import com.melih.launches.data.LaunchDetailItem
import com.melih.launches.data.LaunchDetailMapper
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -21,6 +25,9 @@ abstract class DetailFragmentModule {
@IntoMap
@ViewModelKey(DetailViewModel::class)
abstract fun detailViewModel(detailViewModel: DetailViewModel): ViewModel
@Binds
abstract fun detailMapper(mapper: LaunchDetailMapper): Mapper<LaunchEntity, LaunchDetailItem>
//endregion
@Module

View File

@@ -1,34 +1,31 @@
package com.melih.detail.ui
import androidx.lifecycle.Transformations.map
import com.melih.abstractions.deliverable.handle
import com.melih.core.base.viewmodel.BaseViewModel
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.GetLaunchDetails
import com.melih.repository.interactors.base.handle
import com.melih.interactors.GetLaunchDetails
import com.melih.launches.data.LaunchDetailItem
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import javax.inject.Inject
class DetailViewModel @Inject constructor(
private val getLaunchDetails: GetLaunchDetails,
private val getLaunchDetails: GetLaunchDetails<LaunchDetailItem>,
private val getLaunchDetailsParams: GetLaunchDetails.Params
) : BaseViewModel<LaunchEntity>() {
) : BaseViewModel<LaunchDetailItem>() {
//region Properties
val rocketName = map(successData) {
it.rocket.name
it.rocketName
}
val description = map(successData) {
if (it.missions.isEmpty()) {
""
} else {
it.missions[0].description
}
it.missionDescription
}
val imageUrl = map(successData) {
it.rocket.imageURL
it.imageUrl
}
//endregion

View File

@@ -1,69 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="DetailBinding">
<data class="DetailBinding">
<variable
name="viewModel"
type="com.melih.detail.ui.DetailViewModel" />
</data>
<variable
name="viewModel"
type="com.melih.detail.ui.DetailViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imgRocket"
imageUrl="@{viewModel.imageUrl}"
android:layout_width="0dp"
android:layout_height="220dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" />
<ImageView
android:id="@+id/imgRocket"
imageUrl="@{viewModel.imageUrl}"
android:layout_width="0dp"
android:layout_height="220dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvTitle"
style="@style/AppTheme.TextViewStyle.Title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:text="@{viewModel.rocketName}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvTitle"
style="@style/AppTheme.TextViewStyle.Title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:text="@{viewModel.rocketName}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvDescription"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:layout_marginBottom="@dimen/padding_standard"
android:text="@{viewModel.description}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvDescription"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:layout_marginBottom="@dimen/padding_standard"
android:text="@{viewModel.description}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View File

@@ -1,7 +1,8 @@
package com.melih.detail
import com.melih.detail.ui.DetailViewModel
import com.melih.repository.interactors.GetLaunchDetails
import com.melih.interactors.GetLaunchDetails
import com.melih.launches.data.LaunchDetailItem
import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk
@@ -19,7 +20,7 @@ import org.junit.jupiter.api.Test
@UseExperimental(ExperimentalCoroutinesApi::class)
class DetailViewModelTest : BaseTestWithMainThread() {
private val getLaunchDetails: GetLaunchDetails = mockk(relaxed = true)
private val getLaunchDetails: GetLaunchDetails<LaunchDetailItem> = mockk(relaxed = true)
private val getLaunchDetailsParams = GetLaunchDetails.Params(1013)
private val viewModel = spyk(DetailViewModel(getLaunchDetails, getLaunchDetailsParams))

View File

@@ -7,8 +7,6 @@ apply from: "$rootProject.projectDir/scripts/feature_module.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':repository')
implementation libraries.paging
implementation libraries.swipeRefreshLayout

View File

@@ -0,0 +1,10 @@
package com.melih.launches.data
import com.melih.abstractions.data.ViewEntity
data class LaunchItem(
val id: Long,
val imageUrl: String,
val rocketName: String,
val missionDescription: String
) : ViewEntity

View File

@@ -0,0 +1,18 @@
package com.melih.launches.data
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
import javax.inject.Inject
class LaunchMapper @Inject constructor() : Mapper<LaunchEntity, LaunchItem>() {
override fun convert(launchEntity: LaunchEntity) =
with(launchEntity) {
LaunchItem(
id,
rocket.imageURL,
rocket.name,
if (!missions.isNullOrEmpty()) missions[0].description else ""
)
}
}

View File

@@ -2,10 +2,14 @@ package com.melih.launches.di.modules
import androidx.lifecycle.ViewModel
import androidx.paging.Config
import com.melih.abstractions.mapper.Mapper
import com.melih.core.di.keys.ViewModelKey
import com.melih.definitions.entities.LaunchEntity
import com.melih.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.interactors.GetLaunches
import com.melih.launches.data.LaunchItem
import com.melih.launches.data.LaunchMapper
import com.melih.launches.ui.vm.LaunchesViewModel
import com.melih.repository.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.repository.interactors.GetLaunches
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -14,12 +18,15 @@ import dagger.multibindings.IntoMap
@Module
abstract class LaunchesFragmentModule {
//region ViewModels
//region Binds
@Binds
@IntoMap
@ViewModelKey(LaunchesViewModel::class)
abstract fun listViewModel(listViewModel: LaunchesViewModel): ViewModel
abstract fun launchesViewModel(listViewModel: LaunchesViewModel): ViewModel
@Binds
abstract fun launchMapper(mapper: LaunchMapper): Mapper<LaunchEntity, LaunchItem>
//endregion
@Module

View File

@@ -4,18 +4,18 @@ import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.melih.abstractions.deliverable.State
import com.melih.core.actions.openDetail
import com.melih.core.base.lifecycle.BaseDaggerFragment
import com.melih.core.extensions.observe
import com.melih.interactors.error.PersistenceEmptyError
import com.melih.launches.R
import com.melih.launches.databinding.ListBinding
import com.melih.launches.data.LaunchItem
import com.melih.launches.databinding.LaunchesBinding
import com.melih.launches.ui.adapters.LaunchesAdapter
import com.melih.launches.ui.vm.LaunchesViewModel
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.State
class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.OnRefreshListener {
class LaunchesFragment : BaseDaggerFragment<LaunchesBinding>(), SwipeRefreshLayout.OnRefreshListener {
//region Properties
@@ -67,7 +67,7 @@ class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.O
// Observing error to show toast with retry action
observe(viewModel.errorData) {
if (it !is PersistenceEmpty) {
if (it !is PersistenceEmptyError) {
showSnackbarWithAction(it) {
viewModel.retry()
}
@@ -79,7 +79,7 @@ class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.O
}
}
private fun onItemSelected(item: LaunchEntity) {
private fun onItemSelected(item: LaunchItem) {
openDetail(item.id)
}

View File

@@ -5,10 +5,10 @@ import android.view.ViewGroup
import com.melih.core.base.recycler.BasePagingListAdapter
import com.melih.core.base.recycler.BaseViewHolder
import com.melih.core.extensions.createDiffCallback
import com.melih.launches.data.LaunchItem
import com.melih.launches.databinding.LaunchRowBinding
import com.melih.repository.entities.LaunchEntity
class LaunchesAdapter(itemClickListener: (LaunchEntity) -> Unit) : BasePagingListAdapter<LaunchEntity>(
class LaunchesAdapter(itemClickListener: (LaunchItem) -> Unit) : BasePagingListAdapter<LaunchItem>(
createDiffCallback { oldItem, newItem -> oldItem.id == newItem.id },
itemClickListener
) {
@@ -19,21 +19,18 @@ class LaunchesAdapter(itemClickListener: (LaunchEntity) -> Unit) : BasePagingLis
inflater: LayoutInflater,
parent: ViewGroup,
viewType: Int
): BaseViewHolder<LaunchEntity> =
): BaseViewHolder<LaunchItem> =
LaunchesViewHolder(LaunchRowBinding.inflate(inflater, parent, false))
//endregion
}
class LaunchesViewHolder(private val binding: LaunchRowBinding) : BaseViewHolder<LaunchEntity>(binding) {
class LaunchesViewHolder(private val binding: LaunchRowBinding) :
BaseViewHolder<LaunchItem>(binding) {
//region Functions
override fun bind(item: LaunchEntity) {
override fun bind(item: LaunchItem) {
binding.entity = item
val missions = item.missions
binding.tvDescription.text = if (!missions.isNullOrEmpty()) missions[0].description else ""
binding.executePendingBindings()
}
//endregion

View File

@@ -1,25 +1,23 @@
package com.melih.launches.ui.paging
import com.melih.core.base.paging.BasePagingDataSource
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.GetLaunches
import com.melih.repository.interactors.base.Result
import com.melih.interactors.GetLaunches
import com.melih.launches.data.LaunchItem
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Uses [GetLaunches] to get data for pagination
*/
class LaunchesPagingSource @Inject constructor(
private val getLaunches: GetLaunches,
private val getLaunches: GetLaunches<LaunchItem>,
private val getLaunchesParams: GetLaunches.Params
) : BasePagingDataSource<LaunchEntity>() {
) : BasePagingDataSource<LaunchItem>() {
//region Functions
@UseExperimental(ExperimentalCoroutinesApi::class)
override fun loadDataForPage(page: Int): Flow<Result<List<LaunchEntity>>> =
override fun loadDataForPage(page: Int) =
getLaunches(
getLaunchesParams.copy(
page = page

View File

@@ -2,16 +2,16 @@ package com.melih.launches.ui.paging
import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.base.paging.BasePagingFactory
import com.melih.repository.entities.LaunchEntity
import com.melih.launches.data.LaunchItem
import javax.inject.Inject
import javax.inject.Provider
class LaunchesPagingSourceFactory @Inject constructor(
private val sourceProvider: Provider<LaunchesPagingSource>
) : BasePagingFactory<LaunchEntity>() {
) : BasePagingFactory<LaunchItem>() {
//region Functions
override fun createSource(): BasePagingDataSource<LaunchEntity> = sourceProvider.get()
override fun createSource(): BasePagingDataSource<LaunchItem> = sourceProvider.get()
//endregion
}

View File

@@ -3,18 +3,18 @@ package com.melih.launches.ui.vm
import androidx.paging.PagedList
import com.melih.core.base.paging.BasePagingFactory
import com.melih.core.base.viewmodel.BasePagingViewModel
import com.melih.launches.data.LaunchItem
import com.melih.launches.ui.paging.LaunchesPagingSourceFactory
import com.melih.repository.entities.LaunchEntity
import javax.inject.Inject
class LaunchesViewModel @Inject constructor(
private val launchesPagingSourceFactory: LaunchesPagingSourceFactory,
private val launchesPagingConfig: PagedList.Config
) : BasePagingViewModel<LaunchEntity>() {
) : BasePagingViewModel<LaunchItem>() {
//region Properties
override val factory: BasePagingFactory<LaunchEntity>
override val factory: BasePagingFactory<LaunchItem>
get() = launchesPagingSourceFactory
override val config: PagedList.Config

View File

@@ -4,7 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="ListBinding">
<data class="LaunchesBinding">
<variable
name="viewModel"

View File

@@ -1,69 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data class="LaunchRowBinding">
<data class="LaunchRowBinding">
<variable
name="entity"
type="com.melih.repository.entities.LaunchEntity" />
</data>
<variable
name="entity"
type="com.melih.launches.data.LaunchItem" />
</data>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:background="@null"
app:cardElevation="10dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard"
android:background="@null"
app:cardElevation="10dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="160dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="160dp">
<ImageView
android:id="@+id/imgRocket"
imageUrl="@{entity.rocket.imageURL}"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginStart="@dimen/padding_large"
android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" />
<ImageView
android:id="@+id/imgRocket"
imageUrl="@{entity.imageUrl}"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginStart="@dimen/padding_large"
android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvTitle"
style="@style/AppTheme.TextViewStyle.Title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_large"
android:text="@{entity.name}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toTopOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvTitle"
style="@style/AppTheme.TextViewStyle.Title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_large"
android:text="@{entity.rocketName}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toTopOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvDescription"
style="@style/AppTheme.TextViewStyle.Description"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_large"
app:layout_constraintBottom_toBottomOf="@+id/imgRocket"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/tvDescription"
style="@style/AppTheme.TextViewStyle.Description"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_large"
android:text="@{entity.missionDescription}"
app:layout_constraintBottom_toBottomOf="@+id/imgRocket"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</layout>

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,2 +0,0 @@
<manifest package="com.melih.repository" />

View File

@@ -1,16 +0,0 @@
package com.melih.repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Result
/**
* Contract for sources to seperate low level business logic from build and return type
*/
abstract class Repository {
//region Abstractions
internal abstract suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>>
internal abstract suspend fun getLaunchById(id: Long): Result<LaunchEntity>
//endregion
}

View File

@@ -1,55 +0,0 @@
package com.melih.repository.interactors
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.sources.NetworkSource
import com.melih.repository.sources.PersistenceSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
/**
* Gets next given number of launches
*/
@UseExperimental(ExperimentalCoroutinesApi::class)
class GetLaunchDetails @Inject constructor() : BaseInteractor<LaunchEntity, GetLaunchDetails.Params>() {
//region Properties
@field:Inject
internal lateinit var networkSource: NetworkSource
@field:Inject
internal lateinit var persistenceSource: PersistenceSource
//endregion
//region Functions
override suspend fun FlowCollector<Result<LaunchEntity>>.run(params: Params) {
val result = persistenceSource.getLaunchById(params.id)
if (result !is Success) {
when (val response = networkSource.getLaunchById(params.id)) {
// Save result and return again from persistence
is Success -> {
persistenceSource.saveLaunch(response.successData)
emit(persistenceSource.getLaunchById(params.id))
}
// Redirect failure as it is
is Failure -> emit(response)
}
} else {
emit(result)
}
}
//endregion
data class Params(
val id: Long
) : InteractorParameters
}

View File

@@ -1,54 +0,0 @@
package com.melih.repository.interactors
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.sources.NetworkSource
import com.melih.repository.sources.PersistenceSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
const val DEFAULT_LAUNCHES_AMOUNT = 15
/**
* Gets next given number of launches
*/
@UseExperimental(ExperimentalCoroutinesApi::class)
class GetLaunches @Inject constructor() : BaseInteractor<List<LaunchEntity>, GetLaunches.Params>() {
//region Properties
@field:Inject
internal lateinit var networkSource: NetworkSource
@field:Inject
internal lateinit var persistenceSource: PersistenceSource
//endregion
//region Functions
override suspend fun FlowCollector<Result<List<LaunchEntity>>>.run(params: Params) {
// Start network fetch - we're not handling state here to ommit them
networkSource
.getNextLaunches(params.count, params.page)
.also {
if (it is Success) {
persistenceSource.saveLaunches(it.successData)
emit(persistenceSource.getNextLaunches(params.count, params.page))
} else {
emit(it)
emit(persistenceSource.getNextLaunches(params.count, params.page))
}
}
}
//endregion
data class Params(
val count: Int = DEFAULT_LAUNCHES_AMOUNT,
val page: Int
) : InteractorParameters
}

View File

@@ -1,21 +0,0 @@
package com.melih.repository.interactors.base
import androidx.annotation.StringRes
import com.melih.repository.R
/**
* [Failure] reasons
*/
sealed class Reason(@StringRes val messageRes: Int)
//region Subclasses
class NetworkError : Reason(R.string.reason_network)
class EmptyResultError : Reason(R.string.reason_empty_body)
class GenericError : Reason(R.string.reason_generic)
class ResponseError : Reason(R.string.reason_response)
class TimeoutError : Reason(R.string.reason_timeout)
class PersistenceEmpty : Reason(R.string.reason_persistance_empty)
class NoNetworkPersistenceEmpty : Reason(R.string.reason_no_network_persistance_empty)
//endregion

View File

@@ -1,118 +0,0 @@
package com.melih.repository.sources
import android.net.NetworkInfo
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.repository.interactors.base.EmptyResultError
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.NetworkError
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.ResponseError
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.TimeoutError
import com.melih.repository.network.ApiImpl
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Provider
private const val DEFAULT_IMAGE_SIZE = 480
/**
* NetworkSource for fetching results using api and wrapping them as contracted in [repository][Repository],
* returning either [failure][Failure] with proper [reason][Reason] or [success][Success] with data
*/
internal class NetworkSource @Inject constructor(
private val apiImpl: ApiImpl,
private val networkInfoProvider: Provider<NetworkInfo>
) : Repository() {
//region Properties
private val isNetworkConnected: Boolean
get() {
val networkInfo = networkInfoProvider.get()
return networkInfo != null && networkInfo.isConnected
}
//endregion
//region Functions
override suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>> =
safeExecute({
apiImpl.getNextLaunches(count, page * DEFAULT_LAUNCHES_AMOUNT)
}) { entity ->
entity.launches.map { launch ->
if (!launch.rocket.imageURL.isNotBlank()) {
launch.copy(
rocket = launch.rocket.copy(
imageURL = transformImageUrl(
launch.rocket.imageURL,
launch.rocket.imageSizes
)
)
)
} else {
launch
}
}
}
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
safeExecute({
apiImpl.getLaunchById(id)
}) {
if (!it.rocket.imageURL.isNotBlank()) {
it.copy(
rocket = it.rocket.copy(
imageURL = transformImageUrl(it.rocket.imageURL, it.rocket.imageSizes)
)
)
} else {
it
}
}
private inline fun <T, R> safeExecute(
block: () -> Response<T>,
transform: (T) -> R
) =
if (isNetworkConnected) {
try {
block().extractResponseBody(transform)
} catch (e: IOException) {
Failure(TimeoutError())
}
} else {
Failure(NetworkError())
}
private inline fun <T, R> Response<T>.extractResponseBody(transform: (T) -> R) =
if (isSuccessful) {
body()?.let {
Success(transform(it))
} ?: Failure(EmptyResultError())
} else {
Failure(ResponseError())
}
private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) =
try {
val urlSplit = imageUrl.split("_")
val url = urlSplit[0]
val format = urlSplit[1].split(".")[1]
val requestedSize = if (!supportedSizes.contains(DEFAULT_IMAGE_SIZE)) {
supportedSizes.last { it < DEFAULT_IMAGE_SIZE }
} else {
DEFAULT_IMAGE_SIZE
}
"${url}_$requestedSize.$format"
} catch (e: Exception) {
imageUrl
}
//endregion
}

View File

@@ -1,50 +0,0 @@
package com.melih.repository.sources
import android.content.Context
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.persistence.LaunchesDatabase
import javax.inject.Inject
/**
* Persistance source using Room database to save / read objects for SST - offline usage
*/
internal class PersistenceSource @Inject constructor(
ctx: Context
) : Repository() {
//region Functions
private val launchesDatabase = LaunchesDatabase.getInstance(ctx)
override suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>> =
launchesDatabase
.launchesDao
.getLaunches(count, page)
.takeIf { it.isNotEmpty() }
?.run {
Success(this)
} ?: Failure(PersistenceEmpty())
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
launchesDatabase
.launchesDao
.getLaunchById(id)
.takeIf { it != null }
?.run {
Success(this)
} ?: Failure(PersistenceEmpty())
internal suspend fun saveLaunches(launches: List<LaunchEntity>) {
launchesDatabase.launchesDao.saveLaunches(launches)
}
internal suspend fun saveLaunch(launch: LaunchEntity) {
launchesDatabase.launchesDao.saveLaunch(launch)
}
//endregion
}

View File

@@ -1,104 +0,0 @@
package com.melih.repository.sources
import android.net.NetworkInfo
import com.melih.repository.R
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity
import com.melih.repository.interactors.base.EmptyResultError
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.NetworkError
import com.melih.repository.interactors.base.ResponseError
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onSuccess
import com.melih.repository.network.ApiImpl
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import javax.inject.Provider
@UseExperimental(ExperimentalCoroutinesApi::class)
class NetworkSourceTest {
private val apiImpl = mockk<ApiImpl>(relaxed = true)
private val networkInfoProvider = mockk<Provider<NetworkInfo>>(relaxed = true) {
every { get() } returns mockk(relaxed = true)
}
private val source = spyk(NetworkSource(apiImpl, networkInfoProvider))
@Nested
inner class GetNextLaunches {
@Test
fun `should return network error when internet is not connected`() {
every { networkInfoProvider.get().isConnected } returns false
runBlockingTest {
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf NetworkError::class
}
}
}
@Test
fun `should return response error when it is not successful`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns false
runBlockingTest {
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf ResponseError::class
(it as ResponseError).messageRes shouldEqualTo R.string.reason_response
}
}
}
@Test
fun `should return empty result error when body is null`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).body() } returns null
runBlockingTest {
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf EmptyResultError::class
}
}
}
@Test
fun `should return success with data if execution is successful`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).body() } returns LaunchesEntity(launches = listOf(LaunchEntity(id = 1013)))
runBlockingTest {
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Success::class
result.onSuccess {
it shouldBeInstanceOf List::class
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
}
}

View File

@@ -1,73 +0,0 @@
package com.melih.repository.sources
import android.content.Context
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onSuccess
import com.melih.repository.persistence.LaunchesDatabase
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class PersistanceSourceTest {
private val ctx = mockk<Context>(relaxed = true)
private val dbImplementation = mockk<LaunchesDatabase>(relaxed = true)
private val source = spyk(PersistenceSource(ctx))
private val scope = CoroutineScope(Dispatchers.IO)
@BeforeEach
fun setup() {
mockkObject(LaunchesDatabase)
every { LaunchesDatabase.getInstance(ctx) } returns dbImplementation
}
@Nested
inner class GetNextLaunches {
@Test
fun `should return persistance empty error when db is empty`() {
coEvery { dbImplementation.launchesDao.getLaunches(any(), any()) } returns emptyList()
scope.launch {
val result = source.getNextLaunches(10, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf PersistenceEmpty::class
}
}
}
@Test
fun `should return success with data if db is not empty`() {
coEvery { dbImplementation.launchesDao.getLaunches(any(), any()) } returns listOf(LaunchEntity(id = 1013))
scope.launch {
val result = source.getNextLaunches(10, 0)
result shouldBeInstanceOf Success::class
result.onSuccess {
it.isEmpty() shouldBe false
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
}
}

53
scripts/cq/jacoco.gradle Normal file
View File

@@ -0,0 +1,53 @@
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.1"
reportsDir = file("$rootProject.projectDir/reports/jacoco/$project.name")
}
task jacocoTestReport(type: JacocoReport, dependsOn: "testDebugUnitTest") {
group = "Reporting"
description = "Generate Jacoco coverage reports for Debug build"
reports {
xml.enabled = true
html.enabled = true
}
// what to exclude from coverage report
// UI, "noise", generated classes, platform classes, etc.
def excludes = [
'**/R.class',
'**/R$*.class',
'**/*$ViewInjector*.*',
'**/BuildConfig.*',
'**/Manifest*.*',
'**/*Test*.*',
'android/**/*.*',
'**/*Fragment.*',
'**/*Activity.*'
]
// generated classes
getClassDirectories().setFrom(
fileTree(
dir: "$buildDir/intermediates/classes/debug",
excludes: excludes
) + fileTree(
dir: "$buildDir/tmp/kotlin-classes/debug",
excludes: excludes
)
)
// sources
getSourceDirectories().setFrom(
files([
android.sourceSets.main.java.srcDirs,
"src/main/kotlin"
])
)
getExecutionData().setFrom(
files("$buildDir/jacoco/testDebugUnitTest.exec")
)
}

View File

@@ -1,4 +1,6 @@
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
apply from: "$rootProject.projectDir/scripts/cq/detekt.gradle"
apply from: "$rootProject.projectDir/scripts/cq/dokka.gradle"
apply from: "$rootProject.projectDir/scripts/cq/jacoco.gradle"
android {
compileSdkVersion versions.compileSdkVersion

View File

@@ -1,11 +1,10 @@
apply plugin: "de.mannodermaus.android-junit5"
apply from: "$rootProject.projectDir/scripts/detekt.gradle"
apply from: "$rootProject.projectDir/scripts/dokka.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':abstractions')
implementation libraries.kotlin
implementation libraries.dagger
implementation libraries.timber
@@ -18,57 +17,3 @@ dependencies {
testRuntimeOnly testLibraries.jUnitEngine
}
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.1"
reportsDir = file("$rootProject.projectDir/reports/jacoco/$project.name")
}
task jacocoTestReport(type: JacocoReport, dependsOn: "testDebugUnitTest") {
group = "Reporting"
description = "Generate Jacoco coverage reports for Debug build"
reports {
xml.enabled = true
html.enabled = true
}
// what to exclude from coverage report
// UI, "noise", generated classes, platform classes, etc.
def excludes = [
'**/R.class',
'**/R$*.class',
'**/*$ViewInjector*.*',
'**/BuildConfig.*',
'**/Manifest*.*',
'**/*Test*.*',
'android/**/*.*',
'**/*Fragment.*',
'**/*Activity.*'
]
// generated classes
getClassDirectories().setFrom(
fileTree(
dir: "$buildDir/intermediates/classes/debug",
excludes: excludes
) + fileTree(
dir: "$buildDir/tmp/kotlin-classes/debug",
excludes: excludes
)
)
// sources
getSourceDirectories().setFrom(
files([
android.sourceSets.main.java.srcDirs,
"src/main/kotlin"
])
)
getExecutionData().setFrom(
files("$buildDir/jacoco/testDebugUnitTest.exec")
)
}

Some files were not shown because too many files have changed in this diff Show More