mirror of
https://github.com/melihaksoy/Android-Kotlin-Modulerized-CleanArchitecture.git
synced 2026-03-22 09:29:49 +01:00
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:
17
data/definitions/build.gradle
Normal file
17
data/definitions/build.gradle
Normal 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
|
||||
}
|
||||
0
data/definitions/consumer-rules.pro
Normal file
0
data/definitions/consumer-rules.pro
Normal file
21
data/definitions/proguard-rules.pro
vendored
Normal file
21
data/definitions/proguard-rules.pro
vendored
Normal 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
|
||||
3
data/definitions/src/main/AndroidManifest.xml
Normal file
3
data/definitions/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.melih.definitions.entities" />
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.melih.definitions
|
||||
|
||||
internal const val DEFAULT_NAME = "Default name"
|
||||
internal const val EMPTY_STRING = ""
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.melih.definitions.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.melih.definitions.DEFAULT_NAME
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@Entity(tableName = "Launches")
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LaunchEntity(
|
||||
@PrimaryKey val id: Long = 0L,
|
||||
val name: String = DEFAULT_NAME,
|
||||
@field:Json(name = "wsstamp") val launchStartTime: Long = 0L,
|
||||
@field:Json(name = "westamp") val launchEndTime: Long = 0L,
|
||||
val location: LocationEntity = LocationEntity(),
|
||||
val rocket: RocketEntity = RocketEntity(),
|
||||
val missions: List<MissionEntity> = listOf(MissionEntity())
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.melih.definitions.entities
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LaunchesEntity(
|
||||
val id: Long = 0L,
|
||||
val launches: List<LaunchEntity> = listOf(),
|
||||
val total: Int = 0,
|
||||
val offset: Int = 0,
|
||||
val count: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.melih.definitions.entities
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import com.melih.definitions.DEFAULT_NAME
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LocationEntity(
|
||||
@ColumnInfo(name = "id_location") val id: Long = 0L,
|
||||
@ColumnInfo(name = "name_location") val name: String = DEFAULT_NAME,
|
||||
val pads: List<PadEntity> = listOf(PadEntity())
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class PadEntity(
|
||||
@ColumnInfo(name = "id_pad") val id: Long = 0L,
|
||||
@ColumnInfo(name = "name_pad") val name: String = DEFAULT_NAME,
|
||||
val lat: Long = 0L,
|
||||
val long: Long = 0L
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.melih.definitions.entities
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import com.melih.definitions.DEFAULT_NAME
|
||||
import com.melih.definitions.EMPTY_STRING
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class MissionEntity(
|
||||
@ColumnInfo(name = "id_mission") val id: Long = 0L,
|
||||
@ColumnInfo(name = "name_mission") val name: String = DEFAULT_NAME,
|
||||
val description: String = EMPTY_STRING,
|
||||
val typeName: String = EMPTY_STRING
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.melih.definitions.entities
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import com.melih.definitions.DEFAULT_NAME
|
||||
import com.melih.definitions.EMPTY_STRING
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RocketEntity(
|
||||
@ColumnInfo(name = "id_rocket") val id: Long = 0L,
|
||||
@ColumnInfo(name = "name_rocket") val name: String = DEFAULT_NAME,
|
||||
@field:Json(name = "familyname") val familyName: String = DEFAULT_NAME,
|
||||
val imageSizes: IntArray = intArrayOf(),
|
||||
val imageURL: String = EMPTY_STRING
|
||||
)
|
||||
22
data/interactors/build.gradle
Normal file
22
data/interactors/build.gradle
Normal 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
|
||||
}
|
||||
0
data/interactors/consumer-rules.pro
Normal file
0
data/interactors/consumer-rules.pro
Normal file
21
data/interactors/proguard-rules.pro
vendored
Normal file
21
data/interactors/proguard-rules.pro
vendored
Normal 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
|
||||
3
data/interactors/src/main/AndroidManifest.xml
Normal file
3
data/interactors/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.melih.interactors" />
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
|
||||
/**
|
||||
* Base use case that wraps [suspending][suspend] [run] function with [flow][Flow] and returns it for later usage.
|
||||
*/
|
||||
@UseExperimental(ExperimentalCoroutinesApi::class)
|
||||
abstract class BaseInteractor<T, in P : InteractorParameters> {
|
||||
|
||||
//region Abstractions
|
||||
|
||||
protected abstract suspend fun FlowCollector<Result<T>>.run(params: P)
|
||||
//endregion
|
||||
|
||||
//region Functions
|
||||
|
||||
operator fun invoke(params: P) =
|
||||
flow<Result<T>> {
|
||||
emit(State.Loading())
|
||||
run(params)
|
||||
emit(State.Loaded())
|
||||
}.flowOn(Dispatchers.IO)
|
||||
//endregion
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract for parameter classes
|
||||
*/
|
||||
interface InteractorParameters
|
||||
|
||||
/**
|
||||
* Symbolizes absence of parameters for an [interactor][BaseInteractor]
|
||||
*/
|
||||
class None : Any(), InteractorParameters
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
8
data/interactors/src/main/res/values/strings.xml
Normal file
8
data/interactors/src/main/res/values/strings.xml
Normal file
@@ -0,0 +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_response">Woops, seems we got a server error</string>
|
||||
<string name="reason_timeout">Server timed out</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,68 @@
|
||||
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
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.toCollection
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.amshove.kluent.shouldBeInstanceOf
|
||||
import org.amshove.kluent.shouldEqualTo
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.ArrayDeque
|
||||
|
||||
@UseExperimental(ExperimentalCoroutinesApi::class)
|
||||
class BaseInteractorTest {
|
||||
|
||||
val testInteractor = spyk(TestInteractor())
|
||||
val testParams = TestParams()
|
||||
|
||||
@Test
|
||||
fun `BaseInteractor should send states and items emmited by run`() {
|
||||
// Using run blocking due to threading problems in runBlockingTest
|
||||
// See https://github.com/Kotlin/kotlinx.coroutines/issues/1204
|
||||
|
||||
runBlocking {
|
||||
// Get result by invoking
|
||||
val result = testInteractor(testParams)
|
||||
|
||||
// Verify we invoked interactor exactly once
|
||||
coVerify(exactly = 1) { testInteractor.invoke(any()) }
|
||||
|
||||
// Verify result is type of Flow
|
||||
result shouldBeInstanceOf Flow::class
|
||||
|
||||
// This will actually collec the flow
|
||||
val resultDeque = ArrayDeque<Result<Int>>()
|
||||
result.toCollection(resultDeque)
|
||||
|
||||
// We sent exactly 3 items, verify size
|
||||
resultDeque.size shouldEqualTo 3
|
||||
|
||||
// Verify first item is Loading state
|
||||
resultDeque.poll() shouldBeInstanceOf State.Loading::class
|
||||
|
||||
// Verify second item is Success, with default value we set below in TestParams class
|
||||
resultDeque.poll().also {
|
||||
it shouldBeInstanceOf Success::class
|
||||
(it as Success<Int>).successData shouldEqualTo 10
|
||||
}
|
||||
|
||||
// Verify last item is Loaded state
|
||||
resultDeque.poll() shouldBeInstanceOf State.Loaded::class
|
||||
}
|
||||
}
|
||||
|
||||
inner class TestInteractor : BaseInteractor<Int, TestParams>() {
|
||||
|
||||
override suspend fun FlowCollector<Result<Int>>.run(params: TestParams) {
|
||||
emit(Success(params.testValue))
|
||||
}
|
||||
}
|
||||
|
||||
data class TestParams(val testValue: Int = 10) : InteractorParameters
|
||||
}
|
||||
20
data/network/build.gradle
Normal file
20
data/network/build.gradle
Normal 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
|
||||
}
|
||||
0
data/network/consumer-rules.pro
Normal file
0
data/network/consumer-rules.pro
Normal file
21
data/network/proguard-rules.pro
vendored
Normal file
21
data/network/proguard-rules.pro
vendored
Normal 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
|
||||
3
data/network/src/main/AndroidManifest.xml
Normal file
3
data/network/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.melih.network" />
|
||||
28
data/network/src/main/kotlin/com/melih/network/api/Api.kt
Normal file
28
data/network/src/main/kotlin/com/melih/network/api/Api.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.melih.network
|
||||
|
||||
import com.melih.definitions.entities.LaunchEntity
|
||||
import com.melih.definitions.entities.LaunchesEntity
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Retrofit interface for networking
|
||||
*/
|
||||
internal interface Api {
|
||||
|
||||
//region Get
|
||||
|
||||
@GET("launch/next/{count}")
|
||||
suspend fun getNextLaunches(
|
||||
@Path("count") count: Int,
|
||||
@Query("offset") offset: Int
|
||||
): Response<LaunchesEntity>
|
||||
|
||||
@GET("launch/{id}")
|
||||
suspend fun getLaunchById(
|
||||
@Path("id") id: Long
|
||||
): Response<LaunchEntity>
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.melih.network
|
||||
|
||||
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
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
internal const val TIMEOUT_DURATION = 7L
|
||||
|
||||
class ApiImpl @Inject constructor() : Api {
|
||||
|
||||
//region Properties
|
||||
|
||||
private val service by lazy {
|
||||
val moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
Retrofit.Builder()
|
||||
.client(
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
|
||||
.readTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
).build()
|
||||
)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.baseUrl("https://launchlibrary.net/1.4/")
|
||||
.build()
|
||||
.create(Api::class.java)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Functions
|
||||
|
||||
override suspend fun getNextLaunches(
|
||||
count: Int,
|
||||
offset: Int
|
||||
): Response<LaunchesEntity> =
|
||||
service.getNextLaunches(count, offset)
|
||||
|
||||
override suspend fun getLaunchById(
|
||||
id: Long
|
||||
): Response<LaunchEntity> =
|
||||
service.getLaunchById(id)
|
||||
//endregion
|
||||
}
|
||||
31
data/persistence/build.gradle
Normal file
31
data/persistence/build.gradle
Normal file
@@ -0,0 +1,31 @@
|
||||
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"
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments = ["room.schemaLocation": "$rootProject.projectDir/reports/room".toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
|
||||
implementation project(':data:definitions')
|
||||
|
||||
implementation libraries.moshiKotlin
|
||||
implementation libraries.coroutines
|
||||
implementation libraries.room
|
||||
|
||||
kapt annotationProcessors.roomCompiler
|
||||
|
||||
testImplementation testLibraries.coroutinesCore
|
||||
testImplementation testLibraries.coroutinesTest
|
||||
}
|
||||
0
data/persistence/consumer-rules.pro
Normal file
0
data/persistence/consumer-rules.pro
Normal file
21
data/persistence/proguard-rules.pro
vendored
Normal file
21
data/persistence/proguard-rules.pro
vendored
Normal 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
|
||||
3
data/persistence/src/main/AndroidManifest.xml
Normal file
3
data/persistence/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.melih.persistence" />
|
||||
@@ -0,0 +1,53 @@
|
||||
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.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"
|
||||
|
||||
/**
|
||||
* DB that manages launches
|
||||
*/
|
||||
@Database(
|
||||
entities = [LaunchEntity::class],
|
||||
exportSchema = true,
|
||||
version = 1
|
||||
)
|
||||
@TypeConverters(
|
||||
LocationConverter::class,
|
||||
RocketConverter::class,
|
||||
MissionConverter::class
|
||||
)
|
||||
abstract class LaunchesDatabase : RoomDatabase() {
|
||||
|
||||
//region Companion
|
||||
|
||||
companion object {
|
||||
|
||||
private lateinit var instance: LaunchesDatabase
|
||||
|
||||
fun getInstance(ctx: Context): LaunchesDatabase {
|
||||
if (!::instance.isInitialized) {
|
||||
instance = Room.databaseBuilder(ctx, LaunchesDatabase::class.java, DB_NAME)
|
||||
.build()
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Abstractions
|
||||
|
||||
abstract val launchesDao: LaunchesDao
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.melih.persistence.converters
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
|
||||
/**
|
||||
* Base converter for reduced boilerplate code
|
||||
*/
|
||||
abstract class BaseConverter<T> {
|
||||
|
||||
//region Abstractions
|
||||
|
||||
abstract fun getAdapter(moshi: Moshi): JsonAdapter<T>
|
||||
//endregion
|
||||
|
||||
//region Properties
|
||||
|
||||
private val moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
//endregion
|
||||
|
||||
//region Functions
|
||||
|
||||
@TypeConverter
|
||||
fun convertFrom(item: T) =
|
||||
getAdapter(moshi).toJson(item)
|
||||
|
||||
@TypeConverter
|
||||
fun convertTo(string: String) =
|
||||
getAdapter(moshi).fromJson(string)
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.melih.persistence.converters
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
|
||||
/**
|
||||
* Base converter for reduced boilerplate code
|
||||
*/
|
||||
abstract class BaseListConverter<T> {
|
||||
|
||||
//region Abstractions
|
||||
|
||||
abstract fun getAdapter(moshi: Moshi): JsonAdapter<List<T>>
|
||||
//endregion
|
||||
|
||||
//region Properties
|
||||
|
||||
private val moshi = Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
//endregion
|
||||
|
||||
//region Functions
|
||||
|
||||
@TypeConverter
|
||||
fun convertFrom(items: List<T>) =
|
||||
getAdapter(moshi).toJson(items)
|
||||
|
||||
@TypeConverter
|
||||
fun convertTo(string: String): List<T>? =
|
||||
getAdapter(moshi).fromJson(string)
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.melih.persistence.converters
|
||||
|
||||
import com.melih.definitions.entities.LocationEntity
|
||||
import com.melih.definitions.entities.LocationEntityJsonAdapter
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
|
||||
/**
|
||||
* Converts [location][LocationEntity]
|
||||
*/
|
||||
class LocationConverter : BaseConverter<LocationEntity>() {
|
||||
|
||||
override fun getAdapter(moshi: Moshi): JsonAdapter<LocationEntity> =
|
||||
LocationEntityJsonAdapter(moshi)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.melih.persistence.converters
|
||||
|
||||
import com.melih.definitions.entities.MissionEntity
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
||||
/**
|
||||
* Converts [mission][MissionEntity]
|
||||
*/
|
||||
class MissionConverter : BaseListConverter<MissionEntity>() {
|
||||
|
||||
override fun getAdapter(moshi: Moshi): JsonAdapter<List<MissionEntity>> =
|
||||
moshi.adapter(
|
||||
Types.newParameterizedType(
|
||||
List::class.java,
|
||||
MissionEntity::class.java
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.melih.persistence.converters
|
||||
|
||||
import com.melih.definitions.entities.RocketEntity
|
||||
import com.melih.definitions.entities.RocketEntityJsonAdapter
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.Moshi
|
||||
|
||||
/**
|
||||
* Converts [rocket][RocketEntity]
|
||||
*/
|
||||
class RocketConverter : BaseConverter<RocketEntity>() {
|
||||
|
||||
override fun getAdapter(moshi: Moshi): JsonAdapter<RocketEntity> =
|
||||
RocketEntityJsonAdapter(moshi)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.melih.persistence.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.melih.definitions.entities.LaunchEntity
|
||||
|
||||
/**
|
||||
* DAO for list of [launches][LaunchEntity]
|
||||
*/
|
||||
@Dao
|
||||
abstract class LaunchesDao {
|
||||
|
||||
//region Queries
|
||||
|
||||
@Query("SELECT * FROM Launches ORDER BY launchStartTime DESC LIMIT :count OFFSET :page*:count")
|
||||
abstract suspend fun getLaunches(count: Int, page: Int): List<LaunchEntity>
|
||||
|
||||
@Query("SELECT * FROM Launches WHERE id=:id LIMIT 1")
|
||||
abstract suspend fun getLaunchById(id: Long): LaunchEntity?
|
||||
|
||||
@Query("DELETE FROM Launches")
|
||||
abstract suspend fun nukeLaunches()
|
||||
//endregion
|
||||
|
||||
//region Insertion
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
abstract suspend fun saveLaunches(launches: List<LaunchEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
abstract suspend fun saveLaunch(launch: LaunchEntity)
|
||||
//endregion
|
||||
}
|
||||
Reference in New Issue
Block a user