├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-mdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xhdpi │ │ │ │ └── ic_launcher.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ └── ic_launcher.png │ │ │ └── mipmap-xxxhdpi │ │ │ │ └── ic_launcher.png │ │ ├── java │ │ │ └── io │ │ │ │ └── dotanuki │ │ │ │ └── blockked │ │ │ │ ├── di │ │ │ │ └── loggerModule.kt │ │ │ │ ├── BlockkedApp.kt │ │ │ │ └── LogcatLogger.kt │ │ └── AndroidManifest.xml │ └── androidTest │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── blockked │ │ ├── rx2idlerktx │ │ ├── WorkDelegate.kt │ │ ├── IdlingResourceScheduler.kt │ │ ├── ScheduledWorkDisposable.kt │ │ ├── Rx2IdlerKtx.kt │ │ ├── ScheduledWork.kt │ │ └── DelegatingIdlingResourceScheduler.kt │ │ ├── rules │ │ └── BindingsOverwriter.kt │ │ ├── SwipeToRefreshMatcher.kt │ │ ├── scenarioComposer.kt │ │ ├── dashboardVerifications.kt │ │ └── DashboardAcceptanceTests.kt └── build.gradle ├── domain ├── .gitignore ├── src │ └── main │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── blockked │ │ └── domain │ │ ├── TimeBasedMeasure.kt │ │ ├── FetchStrategy.kt │ │ ├── FetchBitcoinStatistic.kt │ │ ├── BitcoinStatistic.kt │ │ ├── NetworkingIssue.kt │ │ ├── RemoteIntegrationIssue.kt │ │ ├── RetrieveStatistics.kt │ │ └── SupportedStatistic.kt └── build.gradle ├── logger ├── .gitignore ├── build.gradle ├── src │ └── main │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── logger │ │ ├── Logger.kt │ │ ├── MutedLogger.kt │ │ ├── TraceInspector.kt │ │ └── ConsoleLogger.kt └── proguard-rules.pro ├── networking ├── .gitignore ├── build.gradle └── src │ ├── main │ └── java │ │ └── io │ │ └── dotanuki │ │ └── networking │ │ ├── di │ │ └── networkingModule.kt │ │ ├── BuildRetrofit.kt │ │ ├── HandleErrorByHTTPStatus.kt │ │ └── HandleConnectivityIssue.kt │ └── test │ └── java │ └── io │ └── dotanuki │ └── networking │ └── tests │ ├── HandleConnectivityIssueTests.kt │ └── HandleErrorByHttpStatusTests.kt ├── features-common ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ └── values │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── styles.xml │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── io │ │ │ └── dotanuki │ │ │ └── common │ │ │ ├── dateUtil.kt │ │ │ ├── di │ │ │ └── sharedModule.kt │ │ │ ├── Disposer.kt │ │ │ └── ui_state_machine.kt │ └── test │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── common │ │ └── tests │ │ └── StateMachineTests.kt └── build.gradle ├── service-cache ├── .gitignore ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── services │ │ └── cache │ │ ├── di │ │ └── serviceCacheModule.kt │ │ └── PersistantCache.kt └── build.gradle ├── services-common ├── .gitignore ├── src │ ├── main │ │ └── java │ │ │ └── io │ │ │ └── dotanuki │ │ │ └── services │ │ │ └── common │ │ │ ├── CacheEntry.kt │ │ │ ├── BlockchainInfoService.kt │ │ │ ├── CacheService.kt │ │ │ ├── models.kt │ │ │ └── BitcoinInfoMapper.kt │ └── test │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── services │ │ └── common │ │ └── tests │ │ └── BitcoinInfoMapperTests.kt └── build.gradle ├── services-meshing ├── .gitignore ├── src │ ├── test │ │ ├── resources │ │ │ └── mockito-extensions │ │ │ │ └── org.mockito.plugins.MockMaker │ │ └── java │ │ │ └── io │ │ │ └── dotanuki │ │ │ └── services │ │ │ └── mesh │ │ │ └── tests │ │ │ └── FetcherStrategistTests.kt │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── services │ │ └── mesh │ │ ├── di │ │ └── meshModule.kt │ │ └── FetcherStrategist.kt └── build.gradle ├── feature-dashboards ├── .gitignore ├── src │ ├── test │ │ ├── resources │ │ │ └── mockito-extensions │ │ │ │ └── org.mockito.plugins.MockMaker │ │ └── java │ │ │ └── io │ │ │ └── dotanuki │ │ │ └── blockked │ │ │ └── dashboards │ │ │ └── tests │ │ │ ├── DashboardViewModelTests.kt │ │ │ └── BuildDashboardPresentationTests.kt │ └── main │ │ ├── AndroidManifest.xml │ │ ├── res │ │ ├── values │ │ │ └── strings.xml │ │ ├── layout │ │ │ ├── activity_dashboard.xml │ │ │ └── view_dashboard.xml │ │ └── layout-land │ │ │ └── view_dashboard.xml │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── blockked │ │ └── dashboards │ │ ├── DashboardViewModel.kt │ │ ├── di │ │ └── dashboardModule.kt │ │ ├── dashboardPresentation.kt │ │ ├── DashboardEntry.kt │ │ ├── BuildDashboardPresentation.kt │ │ └── DashboardActivity.kt └── build.gradle ├── service-blockchaininfo ├── .gitignore ├── src │ ├── test │ │ ├── resources │ │ │ ├── 404-not-found.json │ │ │ ├── 200OK-market-price-broken.json │ │ │ ├── 503-internal-server.json │ │ │ └── 200OK-market-price.json │ │ └── java │ │ │ └── io │ │ │ └── dotanuki │ │ │ └── blockchainservice │ │ │ └── tests │ │ │ ├── util │ │ │ ├── readResourceFile.kt │ │ │ └── InfrastructureRule.kt │ │ │ ├── HandleSerializationErrorTests.kt │ │ │ └── BlockchainInfoInfrastructureTests.kt │ └── main │ │ └── java │ │ └── io │ │ └── dotanuki │ │ └── service │ │ └── blockchaininfo │ │ ├── BlockchainInfo.kt │ │ ├── ExecutionErrorHandler.kt │ │ ├── BrokerInfrastructure.kt │ │ ├── util │ │ └── HandleSerializationError.kt │ │ └── di │ │ └── serviceBlockchainInfoModule.kt └── build.gradle ├── proguard ├── okio.pro ├── kotlinxserialization.pro ├── retrofit2.pro └── okhttp3.pro ├── .github ├── blockked-logo.png ├── blockked-screenshot.png └── PULL_REQUEST_TEMPLATE.md ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .editorconfig ├── settings.gradle ├── gradle.properties ├── .gitignore ├── LICENSE.md ├── gradlew.bat ├── README.md └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /logger/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /networking/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /features-common/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /service-cache/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /services-common/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /services-meshing/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /feature-dashboards/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /service-blockchaininfo/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /proguard/okio.pro: -------------------------------------------------------------------------------- 1 | -dontwarn org.codehaus.mojo.animal_sniffer.* -------------------------------------------------------------------------------- /feature-dashboards/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /services-meshing/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline -------------------------------------------------------------------------------- /service-blockchaininfo/src/test/resources/404-not-found.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "not-found", 3 | "error": 404 4 | } -------------------------------------------------------------------------------- /.github/blockked-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekuroporisu/n26-android-challenge/HEAD/.github/blockked-logo.png -------------------------------------------------------------------------------- /.github/blockked-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekuroporisu/n26-android-challenge/HEAD/.github/blockked-screenshot.png -------------------------------------------------------------------------------- /features-common/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Blockked 3 | 4 | -------------------------------------------------------------------------------- /service-blockchaininfo/src/test/resources/200OK-market-price-broken.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "not-found", 3 | "error": null 4 | } -------------------------------------------------------------------------------- /service-blockchaininfo/src/test/resources/503-internal-server.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "internal-server-error", 3 | "error": 503 4 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekuroporisu/n26-android-challenge/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekuroporisu/n26-android-challenge/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekuroporisu/n26-android-challenge/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekuroporisu/n26-android-challenge/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekuroporisu/n26-android-challenge/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekuroporisu/n26-android-challenge/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /features-common/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /service-cache/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /services-meshing/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /feature-dashboards/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /feature-dashboards/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Something went wrong ... 4 | -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/rx2idlerktx/WorkDelegate.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.rx2idlerktx 2 | 3 | interface WorkDelegate{ 4 | 5 | fun startWork() 6 | 7 | fun stopWork() 8 | 9 | } -------------------------------------------------------------------------------- /domain/src/main/java/io/dotanuki/blockked/domain/TimeBasedMeasure.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.domain 2 | 3 | import java.util.* 4 | 5 | data class TimeBasedMeasure( 6 | val dateTime: Date, 7 | val value: Float 8 | ) -------------------------------------------------------------------------------- /domain/src/main/java/io/dotanuki/blockked/domain/FetchStrategy.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.domain 2 | 3 | sealed class FetchStrategy { 4 | 5 | object FromPrevious : FetchStrategy() 6 | object ForceUpdate : FetchStrategy() 7 | 8 | } -------------------------------------------------------------------------------- /logger/build.gradle: -------------------------------------------------------------------------------- 1 | apply from: "../build-system/kotlin-module.gradle" 2 | 3 | dependencies { 4 | 5 | StandaloneModule.main.forEach { implementation it } 6 | StandaloneModule.unitTesting.forEach { testImplementation it } 7 | 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # space indentation for Kotlin 7 | [*.{kt,kts}] 8 | indent_style = space 9 | indent_size = 4 10 | continuation_indent_size = 4 -------------------------------------------------------------------------------- /domain/build.gradle: -------------------------------------------------------------------------------- 1 | apply from: "../build-system/kotlin-module.gradle" 2 | 3 | dependencies { 4 | 5 | StandaloneModule.main.forEach { implementation it } 6 | StandaloneModule.unitTesting.forEach { testImplementation it } 7 | 8 | } 9 | 10 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.3-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /service-blockchaininfo/src/test/java/io/dotanuki/blockchainservice/tests/util/readResourceFile.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockchainservice.tests.util 2 | 3 | fun Any.loadFile(path: String) = 4 | this.javaClass.classLoader.getResourceAsStream(path).bufferedReader().use { it.readText() } -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/rx2idlerktx/IdlingResourceScheduler.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.rx2idlerktx 2 | 3 | import androidx.test.espresso.IdlingResource 4 | import io.reactivex.Scheduler 5 | 6 | 7 | abstract class IdlingResourceScheduler : Scheduler(), IdlingResource -------------------------------------------------------------------------------- /features-common/src/main/java/io/dotanuki/common/dateUtil.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.common 2 | 3 | import java.text.SimpleDateFormat 4 | import java.util.* 5 | 6 | fun String.toDate(): Date { 7 | val formatter = SimpleDateFormat("yyyy-MM-dd") 8 | return formatter.parse(this) 9 | } 10 | -------------------------------------------------------------------------------- /domain/src/main/java/io/dotanuki/blockked/domain/FetchBitcoinStatistic.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.domain 2 | 3 | import io.reactivex.Observable 4 | 5 | interface FetchBitcoinStatistic { 6 | 7 | fun execute(target: SupportedStatistic, strategy: FetchStrategy): Observable 8 | 9 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | include ':domain' 3 | include ':logger' 4 | include ':networking' 5 | include ':service-blockchaininfo' 6 | include ':service-cache' 7 | include ':services-common' 8 | include ':services-meshing' 9 | include ':features-common' 10 | include ':feature-dashboards' 11 | -------------------------------------------------------------------------------- /domain/src/main/java/io/dotanuki/blockked/domain/BitcoinStatistic.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.domain 2 | 3 | data class BitcoinStatistic( 4 | 5 | val providedName: String, 6 | val providedDescription: String, 7 | val unitName: String, 8 | val measures: List 9 | 10 | ) -------------------------------------------------------------------------------- /logger/src/main/java/io/dotanuki/logger/Logger.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.logger 2 | 3 | interface Logger { 4 | 5 | fun v(message: String) 6 | 7 | fun d(message: String) 8 | 9 | fun i(message: String) 10 | 11 | fun w(message: String) 12 | 13 | fun e(message: String) 14 | 15 | } 16 | 17 | -------------------------------------------------------------------------------- /features-common/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #004171 5 | #003355 6 | #00A5C2 7 | 8 | #F5F5F5 9 | 10 | 11 | -------------------------------------------------------------------------------- /services-common/src/main/java/io/dotanuki/services/common/CacheEntry.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.common 2 | 3 | sealed class CacheEntry { 4 | 5 | object BTCPrice : CacheEntry() 6 | 7 | override fun toString() = when (this) { 8 | is BTCPrice -> "average-bitcoin-price" 9 | else -> super.toString() 10 | } 11 | 12 | } -------------------------------------------------------------------------------- /services-common/src/main/java/io/dotanuki/services/common/BlockchainInfoService.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.common 2 | 3 | import io.dotanuki.blockked.domain.SupportedStatistic 4 | import io.reactivex.Observable 5 | 6 | interface BlockchainInfoService { 7 | 8 | fun fetchStatistics(statistic : SupportedStatistic) : Observable 9 | 10 | } 11 | -------------------------------------------------------------------------------- /logger/src/main/java/io/dotanuki/logger/MutedLogger.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.logger 2 | 3 | object MutedLogger : Logger { 4 | 5 | override fun v(message: String) = Unit 6 | 7 | override fun d(message: String) = Unit 8 | 9 | override fun i(message: String) = Unit 10 | 11 | override fun w(message: String) = Unit 12 | 13 | override fun e(message: String) = Unit 14 | 15 | } -------------------------------------------------------------------------------- /services-common/build.gradle: -------------------------------------------------------------------------------- 1 | apply from: "../build-system/kotlin-module.gradle" 2 | apply plugin: 'kotlinx-serialization' 3 | 4 | dependencies { 5 | 6 | StandaloneModule.main.forEach { implementation it } 7 | StandaloneModule.unitTesting.forEach { testImplementation it } 8 | 9 | implementation project(':domain') 10 | 11 | implementation Dependencies.kotlinSerialization 12 | } 13 | 14 | -------------------------------------------------------------------------------- /service-blockchaininfo/src/test/resources/200OK-market-price.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Average USD market value across major bitcoin exchanges.", 3 | "name": "Market Price (USD)", 4 | "period": "day", 5 | "status": "ok", 6 | "unit": "USD", 7 | "values": [ 8 | { 9 | "x": 1540166400, 10 | "y": 6498 11 | }, 12 | { 13 | "x": 1540252800, 14 | "y": 6481 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /app/src/main/java/io/dotanuki/blockked/di/loggerModule.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.di 2 | 3 | import io.dotanuki.blockked.LogcatLogger 4 | import io.dotanuki.logger.Logger 5 | import org.kodein.di.Kodein 6 | import org.kodein.di.generic.bind 7 | import org.kodein.di.generic.singleton 8 | 9 | val loggerModule = Kodein.Module("logger") { 10 | 11 | bind() with singleton { 12 | LogcatLogger 13 | } 14 | } -------------------------------------------------------------------------------- /logger/src/main/java/io/dotanuki/logger/TraceInspector.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.logger 2 | 3 | object TraceInspector { 4 | 5 | private const val grandFatherIndex = 2 6 | 7 | fun findClassName(): String { 8 | val stackTraceElement = Throwable().stackTrace[grandFatherIndex] 9 | return stackTraceElement.className 10 | .split(".").last() 11 | .split("$").first() 12 | } 13 | 14 | } -------------------------------------------------------------------------------- /proguard/kotlinxserialization.pro: -------------------------------------------------------------------------------- 1 | -keepattributes *Annotation*, InnerClasses 2 | -dontnote kotlinx.serialization.SerializationKt 3 | -keep,includedescriptorclasses class io.dotanuki.services.common.**$$serializer { *; } 4 | -keepclassmembers class io.dotanuki.services.common.** { 5 | *** Companion; 6 | } 7 | -keepclasseswithmembers class io.dotanuki.services.common.** { 8 | kotlinx.serialization.KSerializer serializer(...); 9 | } -------------------------------------------------------------------------------- /services-common/src/main/java/io/dotanuki/services/common/CacheService.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.common 2 | 3 | import io.dotanuki.blockked.domain.SupportedStatistic 4 | 5 | interface CacheService { 6 | 7 | fun save(key: SupportedStatistic, value: BitcoinStatsResponse) 8 | 9 | fun retrieveOrNull(key: SupportedStatistic): BitcoinStatsResponse? 10 | 11 | fun remove(key: SupportedStatistic) 12 | 13 | fun purge() 14 | 15 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | org.gradle.jvmargs=-Xmx3072m 3 | org.gradle.daemon=true 4 | org.gradle.parallel=true 5 | org.gradle.parallel.threads=4 6 | org.gradle.configureondemand=true 7 | org.gradle.caching=true 8 | 9 | kotlin.parallel.tasks.in.project=true 10 | 11 | android.enableBuildCache=true 12 | android.enableUnitTestBinaryResources=true 13 | android.enableD8.desugaring = true 14 | android.enableR8=true 15 | android.enableR8.fullmode=true -------------------------------------------------------------------------------- /features-common/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /features-common/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply from: "../build-system/android-module.gradle" 3 | 4 | dependencies { 5 | 6 | implementation project(':domain') 7 | implementation project(':logger') 8 | implementation project(':services-meshing') 9 | 10 | AndroidModule.main.forEach { implementation it } 11 | AndroidModule.unitTesting.forEach { testImplementation it } 12 | AndroidModule.androidTesting.forEach { androidTestImplementation it } 13 | } -------------------------------------------------------------------------------- /proguard/retrofit2.pro: -------------------------------------------------------------------------------- 1 | -dontnote retrofit2.Platform 2 | -dontnote retrofit2.Platform$IOS$MainThreadExecutor 3 | -dontwarn retrofit2.Platform$Java8 4 | -keepattributes Signature, InnerClasses, EnclosingMethod, Exceptions 5 | -keepclassmembers,allowshrinking,allowobfuscation interface * { 6 | @retrofit2.http.* ; 7 | } 8 | 9 | -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement 10 | -dontwarn kotlin.Unit 11 | -dontwarn retrofit2.-KotlinExtensions 12 | -dontwarn javax.annotation.** -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/rx2idlerktx/ScheduledWorkDisposable.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.rx2idlerktx 2 | 3 | import io.reactivex.disposables.Disposable 4 | 5 | class ScheduledWorkDisposable( 6 | private val work: ScheduledWork, 7 | private val delegate: Disposable 8 | ) : Disposable { 9 | 10 | override fun isDisposed() = work.get() == ScheduledWork.STATE_DISPOSED 11 | 12 | override fun dispose() { 13 | work.dispose() 14 | delegate.dispose() 15 | } 16 | } -------------------------------------------------------------------------------- /services-common/src/main/java/io/dotanuki/services/common/models.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.common 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class BitcoinStatsResponse( 8 | val name: String, 9 | val description: String, 10 | val unit: String, 11 | val values: List 12 | ) 13 | 14 | @Serializable 15 | data class StatisticPoint( 16 | @SerialName("x") val timestamp: Long, 17 | @SerialName("y") val value: Float 18 | ) 19 | -------------------------------------------------------------------------------- /proguard/okhttp3.pro: -------------------------------------------------------------------------------- 1 | -keepattributes *Annotation*, Signature, Exception 2 | -keep class sun.misc.Unsafe { *; } 3 | -dontwarn java.nio.file.* 4 | -dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement 5 | 6 | -keep class com.google.gson.** { *; } 7 | -dontwarn okio.** 8 | -dontwarn javax.annotation.Nullable 9 | -dontwarn javax.annotation.ParametersAreNonnullByDefault 10 | -dontwarn okhttp3.** 11 | -dontwarn javax.annotation.** 12 | -dontwarn org.conscrypt.** 13 | -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase 14 | 15 | -------------------------------------------------------------------------------- /networking/build.gradle: -------------------------------------------------------------------------------- 1 | apply from: "../build-system/kotlin-module.gradle" 2 | 3 | dependencies { 4 | 5 | implementation project(':domain') 6 | 7 | StandaloneModule.main.forEach { implementation it } 8 | StandaloneModule.unitTesting.forEach { testImplementation it } 9 | 10 | implementation Dependencies.okhttp 11 | implementation Dependencies.okhttpLogger 12 | implementation Dependencies.retrofit 13 | implementation Dependencies.retrofitKTXConverter 14 | implementation Dependencies.retrofitRxAdapter 15 | 16 | } 17 | 18 | -------------------------------------------------------------------------------- /service-cache/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply from: "../build-system/android-module.gradle" 3 | apply plugin: 'kotlinx-serialization' 4 | 5 | dependencies { 6 | 7 | implementation project(':services-common') 8 | implementation project(':domain') 9 | implementation Dependencies.kotlinSerialization 10 | 11 | AndroidModule.main.forEach { implementation it } 12 | AndroidModule.unitTesting.forEach { testImplementation it } 13 | AndroidModule.androidTesting.forEach { androidTestImplementation it } 14 | 15 | } -------------------------------------------------------------------------------- /features-common/src/main/java/io/dotanuki/common/di/sharedModule.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.common.di 2 | 3 | import io.dotanuki.common.Disposer 4 | import io.dotanuki.services.mesh.di.meshModule 5 | import org.kodein.di.Kodein 6 | import org.kodein.di.generic.bind 7 | import org.kodein.di.generic.instance 8 | import org.kodein.di.generic.provider 9 | 10 | val sharedModule = Kodein.Module("shared") { 11 | 12 | importOnce(meshModule) 13 | 14 | bind() from provider { 15 | Disposer( 16 | logger = instance() 17 | ) 18 | } 19 | } -------------------------------------------------------------------------------- /feature-dashboards/src/main/java/io/dotanuki/blockked/dashboards/DashboardViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.dashboards 2 | 3 | import io.dotanuki.blockked.domain.RetrieveStatistics 4 | import io.dotanuki.common.StateMachine 5 | 6 | class DashboardViewModel( 7 | private val machine: StateMachine>, 8 | private val usecase: RetrieveStatistics) { 9 | 10 | fun retrieveDashboard() = 11 | usecase 12 | .execute() 13 | .map { BuildDashboardPresentation(it) } 14 | .compose(machine) 15 | 16 | 17 | } -------------------------------------------------------------------------------- /domain/src/main/java/io/dotanuki/blockked/domain/NetworkingIssue.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.domain 2 | 3 | sealed class NetworkingIssue : Throwable() { 4 | 5 | object HostUnreachable : NetworkingIssue() 6 | object OperationTimeout : NetworkingIssue() 7 | object ConnectionSpike : NetworkingIssue() 8 | 9 | override fun toString() = when (this) { 10 | HostUnreachable -> "Cannot reach remote host" 11 | OperationTimeout -> "Networking operation timed out" 12 | ConnectionSpike -> "In-flight networking operation interrupted" 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /domain/src/main/java/io/dotanuki/blockked/domain/RemoteIntegrationIssue.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.domain 2 | 3 | sealed class RemoteIntegrationIssue : Throwable() { 4 | 5 | object ClientOrigin : RemoteIntegrationIssue() 6 | object RemoteSystem : RemoteIntegrationIssue() 7 | object UnexpectedResponse : RemoteIntegrationIssue() 8 | 9 | override fun toString() = when (this) { 10 | ClientOrigin -> "Issue originated from client" 11 | RemoteSystem -> "Issue incoming from server" 12 | UnexpectedResponse -> "Broken contract" 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/rx2idlerktx/Rx2IdlerKtx.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.rx2idlerktx 2 | 3 | import androidx.test.espresso.Espresso 4 | import io.reactivex.Scheduler 5 | import io.reactivex.functions.Function 6 | import java.util.concurrent.Callable 7 | 8 | object Rx2IdlerKtx { 9 | 10 | fun create(name: String): Function, Scheduler> { 11 | return Function { delegate -> 12 | val scheduler = DelegatingIdlingResourceScheduler(delegate.call(), name) 13 | Espresso.registerIdlingResources(scheduler) 14 | scheduler 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /service-cache/src/main/java/io/dotanuki/services/cache/di/serviceCacheModule.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.cache.di 2 | 3 | import android.app.Application 4 | import io.dotanuki.services.cache.PersistantCache 5 | import io.dotanuki.services.common.CacheService 6 | import org.kodein.di.Kodein 7 | import org.kodein.di.generic.bind 8 | import org.kodein.di.generic.instance 9 | import org.kodein.di.generic.singleton 10 | 11 | val cacheModule = Kodein.Module("service-cache") { 12 | 13 | bind() with singleton { 14 | PersistantCache( 15 | context = instance() 16 | ) 17 | } 18 | } -------------------------------------------------------------------------------- /services-meshing/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply from: "../build-system/android-module.gradle" 3 | 4 | dependencies { 5 | 6 | implementation project(':services-common') 7 | implementation project(':service-cache') 8 | implementation project(':service-blockchaininfo') 9 | implementation project(':domain') 10 | 11 | implementation Dependencies.kotlinSerialization 12 | 13 | AndroidModule.main.forEach { implementation it } 14 | AndroidModule.unitTesting.forEach { testImplementation it } 15 | AndroidModule.androidTesting.forEach { androidTestImplementation it } 16 | 17 | } 18 | 19 | -------------------------------------------------------------------------------- /networking/src/main/java/io/dotanuki/networking/di/networkingModule.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.networking.di 2 | 3 | import okhttp3.OkHttpClient 4 | import okhttp3.logging.HttpLoggingInterceptor 5 | import org.kodein.di.Kodein 6 | import org.kodein.di.generic.bind 7 | import org.kodein.di.generic.singleton 8 | 9 | val networkingModule = Kodein.Module("networking") { 10 | 11 | val logger = HttpLoggingInterceptor().apply { 12 | level = HttpLoggingInterceptor.Level.BODY 13 | } 14 | 15 | bind() from singleton { 16 | OkHttpClient.Builder() 17 | .addInterceptor(logger) 18 | .build() 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /feature-dashboards/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply from: "../build-system/android-module.gradle" 3 | 4 | dependencies { 5 | 6 | implementation project(':domain') 7 | implementation project(':logger') 8 | implementation project(':features-common') 9 | 10 | AndroidModule.main.forEach { implementation it } 11 | AndroidModule.unitTesting.forEach { testImplementation it } 12 | AndroidModule.androidTesting.forEach { androidTestImplementation it } 13 | 14 | implementation Dependencies.groupie 15 | implementation Dependencies.groupieKTX 16 | implementation Dependencies.mpAndroidChart 17 | 18 | } -------------------------------------------------------------------------------- /service-blockchaininfo/src/main/java/io/dotanuki/service/blockchaininfo/BlockchainInfo.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.service.blockchaininfo 2 | 3 | import io.dotanuki.services.common.BitcoinStatsResponse 4 | import io.reactivex.Observable 5 | import retrofit2.http.GET 6 | import retrofit2.http.Path 7 | import retrofit2.http.QueryMap 8 | 9 | internal interface BlockchainInfo { 10 | 11 | @GET("charts/{name}") fun fetchWith( 12 | @Path("name") statistic: String, 13 | @QueryMap query: Map): Observable 14 | 15 | companion object { 16 | const val API_URL = "https://api.blockchain.info/" 17 | } 18 | } -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### :pushpin: Referências 2 | 3 | * **Versão do App**: _Coloque aqui a versão do APK a ser gerado após essa feature/fix/chore_ 4 | * **Issue:** _Link para a issue relacionada_ 5 | * **Pull requests relacionados:** _Lista de PRs relacionados (separados por vírgula): #1, #2_ 6 | 7 | ### :cyclone: Mudanças 8 | 9 | _Descreva as mudanças realizadas em detalhe. Explique as decisões tomadas durante o desenvolvimento_ 10 | 11 | ### :art: Mudanças de UI 12 | 13 | _Screenshots, imagens e/ou vídeos mostrando a feature nova_ 14 | 15 | ### :memo: Notas 16 | 17 | _Notas adicionais para os revisores. Indique seções importantes. Questões que você possa ter. E outros._ 18 | -------------------------------------------------------------------------------- /service-blockchaininfo/build.gradle: -------------------------------------------------------------------------------- 1 | apply from: "../build-system/kotlin-module.gradle" 2 | apply plugin: 'kotlinx-serialization' 3 | 4 | dependencies { 5 | 6 | implementation project(':logger') 7 | implementation project(':networking') 8 | implementation project(':domain') 9 | implementation project(':services-common') 10 | 11 | StandaloneModule.main.forEach { implementation it } 12 | StandaloneModule.unitTesting.forEach { testImplementation it } 13 | 14 | implementation Dependencies.okhttpLogger 15 | implementation Dependencies.retrofit 16 | implementation Dependencies.kotlinSerialization 17 | testImplementation TestDependencies.mockWebServer 18 | 19 | } 20 | 21 | -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/rules/BindingsOverwriter.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.rules 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import io.dotanuki.blockked.BlockkedApp 5 | import org.junit.rules.ExternalResource 6 | import org.kodein.di.Kodein 7 | 8 | class BindingsOverwriter(private val bindings: Kodein.MainBuilder.() -> Unit) : ExternalResource() { 9 | 10 | init { 11 | val configurableKodein = app().kodein 12 | configurableKodein.addConfig { bindings() } 13 | } 14 | 15 | private fun app() = 16 | InstrumentationRegistry 17 | .getInstrumentation() 18 | .targetContext 19 | .applicationContext as BlockkedApp 20 | 21 | } -------------------------------------------------------------------------------- /app/src/main/java/io/dotanuki/blockked/BlockkedApp.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked 2 | 3 | import android.app.Application 4 | import io.dotanuki.blockked.dashboards.di.dashboardModule 5 | import io.dotanuki.blockked.di.loggerModule 6 | import org.kodein.di.KodeinAware 7 | import org.kodein.di.conf.ConfigurableKodein 8 | import org.kodein.di.generic.bind 9 | import org.kodein.di.generic.singleton 10 | 11 | class BlockkedApp : Application(), KodeinAware { 12 | 13 | override val kodein = ConfigurableKodein(mutable = true).apply { 14 | 15 | addImport(loggerModule) 16 | addImport(dashboardModule) 17 | 18 | addConfig { 19 | bind() with singleton { 20 | this@BlockkedApp 21 | } 22 | } 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /features-common/src/main/java/io/dotanuki/common/Disposer.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.common 2 | 3 | import androidx.lifecycle.DefaultLifecycleObserver 4 | import androidx.lifecycle.LifecycleOwner 5 | import io.dotanuki.logger.Logger 6 | import io.reactivex.disposables.CompositeDisposable 7 | import io.reactivex.disposables.Disposable 8 | 9 | class Disposer(private val logger: Logger) : DefaultLifecycleObserver { 10 | 11 | private val trash by lazy { 12 | CompositeDisposable() 13 | } 14 | 15 | override fun onDestroy(owner: LifecycleOwner) { 16 | logger.i("Disposing at onDestroy -> ${trash.size()} items") 17 | trash.clear() 18 | super.onDestroy(owner) 19 | } 20 | 21 | fun collect(target: Disposable) { 22 | trash.add(target) 23 | } 24 | } -------------------------------------------------------------------------------- /logger/src/main/java/io/dotanuki/logger/ConsoleLogger.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.logger 2 | 3 | object ConsoleLogger : Logger { 4 | 5 | override fun v(message: String) { 6 | println("VERBOSE/${TraceInspector.findClassName()}:$message") 7 | } 8 | 9 | override fun d(message: String) { 10 | println("DEBUG/${TraceInspector.findClassName()}:$message") 11 | } 12 | 13 | override fun i(message: String) { 14 | println("INFO/${TraceInspector.findClassName()}:$message") 15 | } 16 | 17 | override fun w(message: String) { 18 | println("WARNING/${TraceInspector.findClassName()}:$message") 19 | } 20 | 21 | override fun e(message: String) { 22 | System.err.println("ERROR/${TraceInspector.findClassName()}:$message") 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /app/src/main/java/io/dotanuki/blockked/LogcatLogger.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked 2 | 3 | import android.util.Log 4 | import io.dotanuki.logger.Logger 5 | import io.dotanuki.logger.TraceInspector 6 | 7 | internal object LogcatLogger : Logger { 8 | 9 | override fun v(message: String) { 10 | Log.v(TraceInspector.findClassName(), message) 11 | } 12 | 13 | override fun d(message: String) { 14 | Log.d(TraceInspector.findClassName(), message) 15 | } 16 | 17 | override fun i(message: String) { 18 | Log.i(TraceInspector.findClassName(), message) 19 | } 20 | 21 | override fun w(message: String) { 22 | Log.w(TraceInspector.findClassName(), message) 23 | } 24 | 25 | override fun e(message: String) { 26 | Log.e(TraceInspector.findClassName(), message) 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /services-meshing/src/main/java/io/dotanuki/services/mesh/di/meshModule.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.mesh.di 2 | 3 | import io.dotanuki.blockked.domain.FetchBitcoinStatistic 4 | import io.dotanuki.service.blockchaininfo.di.blockchainInfoModule 5 | import io.dotanuki.services.cache.di.cacheModule 6 | import io.dotanuki.services.mesh.FetcherStrategist 7 | import org.kodein.di.Kodein 8 | import org.kodein.di.generic.bind 9 | import org.kodein.di.generic.instance 10 | import org.kodein.di.generic.provider 11 | 12 | val meshModule = Kodein.Module("services-mesh") { 13 | 14 | importOnce(cacheModule) 15 | importOnce(blockchainInfoModule) 16 | 17 | bind() with provider { 18 | FetcherStrategist( 19 | remote = instance(), 20 | local = instance() 21 | ) 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /logger/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/SwipeToRefreshMatcher.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked 2 | 3 | import android.view.View 4 | import androidx.swiperefreshlayout.widget.SwipeRefreshLayout 5 | import androidx.test.espresso.matcher.BoundedMatcher 6 | import org.hamcrest.Description 7 | import org.hamcrest.Matcher 8 | 9 | object SwipeRefreshLayoutMatchers { 10 | 11 | @JvmStatic fun isRefreshing(): Matcher { 12 | return object : BoundedMatcher( 13 | SwipeRefreshLayout::class.java) { 14 | 15 | override fun describeTo(description: Description) { 16 | description.appendText("is refreshing") 17 | } 18 | 19 | override fun matchesSafely(view: SwipeRefreshLayout): Boolean { 20 | return view.isRefreshing 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /networking/src/main/java/io/dotanuki/networking/BuildRetrofit.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.networking 2 | 3 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.serializationConverterFactory 4 | import kotlinx.serialization.json.JSON 5 | import okhttp3.MediaType 6 | import okhttp3.OkHttpClient 7 | import retrofit2.Retrofit 8 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 9 | 10 | object BuildRetrofit { 11 | 12 | operator fun invoke(apiURL: String, httpClient: OkHttpClient) = 13 | with(Retrofit.Builder()) { 14 | baseUrl(apiURL) 15 | client(httpClient) 16 | addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 17 | addConverterFactory(serializationConverterFactory(contentType, JSON.nonstrict)) 18 | build() 19 | } 20 | 21 | 22 | private val contentType by lazy { 23 | MediaType.parse("application/json")!! 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /service-blockchaininfo/src/main/java/io/dotanuki/service/blockchaininfo/ExecutionErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.service.blockchaininfo 2 | 3 | import io.dotanuki.logger.Logger 4 | import io.dotanuki.networking.HandleConnectivityIssue 5 | import io.dotanuki.networking.HandleErrorByHttpStatus 6 | import io.dotanuki.service.blockchaininfo.util.HandleSerializationError 7 | import io.reactivex.Observable 8 | import io.reactivex.ObservableTransformer 9 | 10 | class ExecutionErrorHandler(private val logger: Logger) : ObservableTransformer { 11 | 12 | override fun apply(upstream: Observable) = 13 | upstream 14 | .compose(HandleErrorByHttpStatus()) 15 | .compose(HandleConnectivityIssue()) 16 | .compose(HandleSerializationError(logger)) 17 | .doOnError { logger.e("API integration | Failed with -> $it") } 18 | .doOnNext { logger.v("API integration -> Success") } 19 | 20 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | 24 | # Log Files 25 | *.log 26 | stacktrace.txt 27 | 28 | # Android Studio Navigation editor temp files 29 | .navigation/ 30 | 31 | # Android Studio captures folder 32 | captures/ 33 | 34 | # Intellij 35 | *.iml 36 | .idea 37 | .idea/ 38 | .idea/workspace.xml 39 | .idea/tasks.xml 40 | .idea/gradle.xml 41 | .idea/dictionaries 42 | .idea/libraries 43 | 44 | 45 | # External native build folder generated in Android Studio 2.2 and later 46 | .externalNativeBuild 47 | 48 | # Freeline 49 | freeline.py 50 | freeline/ 51 | freeline_project_description.json 52 | 53 | # Release folder 54 | /release/ 55 | 56 | # OSX 57 | .DS_STORE -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /domain/src/main/java/io/dotanuki/blockked/domain/RetrieveStatistics.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.domain 2 | 3 | import io.reactivex.Observable 4 | import io.reactivex.functions.Function 5 | 6 | class RetrieveStatistics(private val fetcher: FetchBitcoinStatistic) { 7 | 8 | private val cached by lazy { 9 | retrieveAll(strategy = FetchStrategy.FromPrevious) 10 | } 11 | 12 | private val updated by lazy { 13 | retrieveAll(strategy = FetchStrategy.ForceUpdate) 14 | } 15 | 16 | fun execute() = cached.concatWith(updated) 17 | 18 | private fun retrieveAll(strategy: FetchStrategy) = 19 | Observable 20 | .fromIterable(SupportedStatistic.ALL) 21 | .flatMap { Observable.just(fetcher.execute(it, strategy)) } 22 | .let { Observable.zip(it, Zipper) } 23 | 24 | private companion object Zipper : Function, List> { 25 | override fun apply(raw: Array) = raw.map { it as BitcoinStatistic } 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /feature-dashboards/src/main/java/io/dotanuki/blockked/dashboards/di/dashboardModule.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.dashboards.di 2 | 3 | import io.dotanuki.blockked.dashboards.DashboardViewModel 4 | import io.dotanuki.blockked.domain.RetrieveStatistics 5 | import io.dotanuki.common.StateMachine 6 | import io.dotanuki.common.di.sharedModule 7 | import io.reactivex.android.schedulers.AndroidSchedulers 8 | import org.kodein.di.Kodein 9 | import org.kodein.di.generic.bind 10 | import org.kodein.di.generic.instance 11 | import org.kodein.di.generic.provider 12 | 13 | val dashboardModule = Kodein.Module("dashboard") { 14 | 15 | import(sharedModule) 16 | 17 | bind() from provider { 18 | 19 | RetrieveStatistics( 20 | fetcher = instance() 21 | ) 22 | } 23 | 24 | 25 | bind() from provider { 26 | 27 | DashboardViewModel( 28 | usecase = instance(), 29 | machine = StateMachine( 30 | uiScheduler = AndroidSchedulers.mainThread() 31 | ) 32 | ) 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /features-common/src/main/java/io/dotanuki/common/ui_state_machine.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.common 2 | 3 | import io.reactivex.Observable 4 | import io.reactivex.ObservableTransformer 5 | import io.reactivex.Scheduler 6 | import io.reactivex.schedulers.Schedulers 7 | 8 | sealed class UIEvent 9 | 10 | object Launched : UIEvent() 11 | data class Failed(val reason: Throwable) : UIEvent() 12 | data class Result(val value: T) : UIEvent() 13 | object Done : UIEvent() 14 | 15 | class StateMachine(private val uiScheduler: Scheduler = Schedulers.trampoline()) 16 | : ObservableTransformer> { 17 | 18 | override fun apply(upstream: Observable): Observable> { 19 | 20 | val beginning = Launched 21 | val end = Observable.just(Done) 22 | 23 | return upstream 24 | .map { value: T -> Result(value) as UIEvent } 25 | .onErrorReturn { error: Throwable -> Failed(error) } 26 | .startWith(beginning) 27 | .concatWith(end) 28 | .observeOn(uiScheduler) 29 | } 30 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Ubiratan Soares 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /service-blockchaininfo/src/main/java/io/dotanuki/service/blockchaininfo/BrokerInfrastructure.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.service.blockchaininfo 2 | 3 | import io.dotanuki.blockked.domain.SupportedStatistic 4 | import io.dotanuki.services.common.BitcoinStatsResponse 5 | import io.dotanuki.services.common.BlockchainInfoService 6 | import io.reactivex.Observable 7 | import io.reactivex.Scheduler 8 | import io.reactivex.schedulers.Schedulers 9 | 10 | internal class BrokerInfrastructure( 11 | private val service: BlockchainInfo, 12 | private val errorHandler: ExecutionErrorHandler, 13 | private val targetScheduler: Scheduler = Schedulers.trampoline()) : BlockchainInfoService { 14 | 15 | override fun fetchStatistics(statistic: SupportedStatistic): Observable { 16 | return service 17 | .fetchWith(statistic.toString(), ARGS) 18 | .subscribeOn(targetScheduler) 19 | .compose(errorHandler) 20 | } 21 | 22 | private companion object { 23 | val ARGS = mapOf( 24 | "timespan" to "4weeks", 25 | "format" to "json" 26 | ) 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /networking/src/main/java/io/dotanuki/networking/HandleErrorByHTTPStatus.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.networking 2 | 3 | import io.dotanuki.blockked.domain.RemoteIntegrationIssue 4 | import io.reactivex.Observable 5 | import io.reactivex.ObservableSource 6 | import io.reactivex.ObservableTransformer 7 | import retrofit2.HttpException 8 | 9 | class HandleErrorByHttpStatus : ObservableTransformer { 10 | 11 | override fun apply(upstream: Observable): ObservableSource { 12 | return upstream.onErrorResumeNext(this::handleIfRestError) 13 | } 14 | 15 | private fun handleIfRestError(incoming: Throwable): Observable = 16 | if (incoming is HttpException) toInfrastructureError(incoming) 17 | else Observable.error(incoming) 18 | 19 | private fun toInfrastructureError(restError: HttpException): Observable { 20 | val infraError = mapErrorWith(restError.code()) 21 | return Observable.error(infraError) 22 | } 23 | 24 | private fun mapErrorWith(code: Int) = when (code) { 25 | in 400..499 -> RemoteIntegrationIssue.ClientOrigin 26 | else -> RemoteIntegrationIssue.RemoteSystem 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /feature-dashboards/src/main/java/io/dotanuki/blockked/dashboards/dashboardPresentation.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.dashboards 2 | 3 | import com.github.mikephil.charting.data.Entry 4 | 5 | data class DashboardPresentation( 6 | val display: DisplayModel, 7 | val chart: ChartModel 8 | ) 9 | 10 | data class DisplayModel( 11 | val formattedValue: String, 12 | val title: String, 13 | val subtitle: String 14 | ) 15 | 16 | sealed class ChartModel { 17 | 18 | object Unavailable : ChartModel() 19 | 20 | data class AvaliableData( 21 | val shouldDiscretize: Boolean = true, 22 | val minValue: Float, 23 | val maxValue: Float, 24 | val legend: String, 25 | val values: List) : ChartModel() 26 | 27 | } 28 | 29 | // Because Entry from MPAndroidChart library does not implement equals() #sadpanda 30 | 31 | interface Plottable { 32 | 33 | val xCoordinate: Float 34 | val yCoordinate: Float 35 | 36 | } 37 | 38 | data class PlottableEntry( 39 | private val a: Float, 40 | private val b: Float) : Entry(a, b), Plottable { 41 | 42 | override val xCoordinate = x 43 | override val yCoordinate = y 44 | 45 | } 46 | -------------------------------------------------------------------------------- /services-common/src/main/java/io/dotanuki/services/common/BitcoinInfoMapper.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.common 2 | 3 | import io.dotanuki.blockked.domain.BitcoinStatistic 4 | import io.dotanuki.blockked.domain.TimeBasedMeasure 5 | import java.util.* 6 | 7 | object BitcoinInfoMapper { 8 | 9 | operator fun invoke(response: BitcoinStatsResponse) = with(response) { 10 | BitcoinStatistic( 11 | providedName = name, 12 | providedDescription = description, 13 | unitName = unit, 14 | measures = values.map { 15 | TimeBasedMeasure( 16 | dateTime = assembleFixedTimeDate(it.timestamp), 17 | value = it.value 18 | ) 19 | } 20 | ) 21 | } 22 | 23 | private fun assembleFixedTimeDate(timestamp: Long): Date { 24 | val timeDoesNotMatter = Date(timestamp * 1000) 25 | 26 | val calendar = Calendar.getInstance().apply { 27 | time = timeDoesNotMatter 28 | set(Calendar.HOUR_OF_DAY, 12) 29 | set(Calendar.MINUTE, 0) 30 | set(Calendar.SECOND, 0) 31 | } 32 | 33 | return calendar.time 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /service-blockchaininfo/src/main/java/io/dotanuki/service/blockchaininfo/util/HandleSerializationError.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.service.blockchaininfo.util 2 | 3 | import io.dotanuki.blockked.domain.RemoteIntegrationIssue 4 | import io.dotanuki.logger.Logger 5 | import io.reactivex.Observable 6 | import io.reactivex.ObservableTransformer 7 | import kotlinx.serialization.MissingFieldException 8 | import kotlinx.serialization.SerializationException 9 | import kotlinx.serialization.UnknownFieldException 10 | 11 | internal class HandleSerializationError(private val logger: Logger) : ObservableTransformer { 12 | 13 | override fun apply(upstream: Observable): Observable { 14 | return upstream.onErrorResumeNext(this::handleSerializationError) 15 | } 16 | 17 | private fun handleSerializationError(error: Throwable): Observable { 18 | error.message?.let { logger.e(it) } ?: error.printStackTrace() 19 | 20 | val mapped = when (error) { 21 | is MissingFieldException, 22 | is UnknownFieldException, 23 | is SerializationException -> RemoteIntegrationIssue.UnexpectedResponse 24 | else -> error 25 | } 26 | 27 | return Observable.error(mapped) 28 | } 29 | } -------------------------------------------------------------------------------- /service-blockchaininfo/src/test/java/io/dotanuki/blockchainservice/tests/util/InfrastructureRule.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockchainservice.tests.util 2 | 3 | import io.dotanuki.networking.BuildRetrofit 4 | import io.dotanuki.service.blockchaininfo.BlockchainInfo 5 | import okhttp3.OkHttpClient 6 | import okhttp3.logging.HttpLoggingInterceptor 7 | import okhttp3.mockwebserver.MockResponse 8 | import okhttp3.mockwebserver.MockWebServer 9 | import org.junit.rules.ExternalResource 10 | 11 | internal class InfrastructureRule: ExternalResource() { 12 | 13 | lateinit var server: MockWebServer 14 | lateinit var api: BlockchainInfo 15 | 16 | override fun before() { 17 | super.before() 18 | server = MockWebServer() 19 | val url = server.url("/").toString() 20 | 21 | val client = OkHttpClient.Builder() 22 | .addInterceptor(HttpLoggingInterceptor()) 23 | .build() 24 | 25 | api = BuildRetrofit(url, client).create(BlockchainInfo::class.java) 26 | 27 | } 28 | 29 | override fun after() { 30 | server.shutdown() 31 | super.after() 32 | } 33 | 34 | fun defineScenario(status: Int, response: String = "") { 35 | 36 | server.enqueue( 37 | MockResponse().apply { 38 | setResponseCode(status) 39 | setBody(response) 40 | } 41 | ) 42 | } 43 | } -------------------------------------------------------------------------------- /services-meshing/src/main/java/io/dotanuki/services/mesh/FetcherStrategist.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.mesh 2 | 3 | import io.dotanuki.blockked.domain.FetchBitcoinStatistic 4 | import io.dotanuki.blockked.domain.FetchStrategy 5 | import io.dotanuki.blockked.domain.FetchStrategy.ForceUpdate 6 | import io.dotanuki.blockked.domain.FetchStrategy.FromPrevious 7 | import io.dotanuki.blockked.domain.SupportedStatistic 8 | import io.dotanuki.services.common.BitcoinInfoMapper 9 | import io.dotanuki.services.common.BlockchainInfoService 10 | import io.dotanuki.services.common.CacheService 11 | import io.reactivex.Observable 12 | 13 | class FetcherStrategist( 14 | private val remote: BlockchainInfoService, 15 | private val local: CacheService) : FetchBitcoinStatistic { 16 | 17 | override fun execute(target: SupportedStatistic, strategy: FetchStrategy) = when (strategy) { 18 | is ForceUpdate -> remoteThenCache(target) 19 | is FromPrevious -> fromCache(target) 20 | } 21 | 22 | private fun fromCache(target: SupportedStatistic) = 23 | local.retrieveOrNull(target) 24 | ?.let { Observable.just(BitcoinInfoMapper(it)) } 25 | ?: Observable.empty() 26 | 27 | private fun remoteThenCache(statistic: SupportedStatistic) = 28 | remote 29 | .fetchStatistics(statistic) 30 | .doOnNext { local.save(statistic, it) } 31 | .map { BitcoinInfoMapper(it) } 32 | 33 | } -------------------------------------------------------------------------------- /service-blockchaininfo/src/main/java/io/dotanuki/service/blockchaininfo/di/serviceBlockchainInfoModule.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.service.blockchaininfo.di 2 | 3 | import io.dotanuki.networking.BuildRetrofit 4 | import io.dotanuki.networking.di.networkingModule 5 | import io.dotanuki.service.blockchaininfo.BlockchainInfo 6 | import io.dotanuki.service.blockchaininfo.BrokerInfrastructure 7 | import io.dotanuki.service.blockchaininfo.ExecutionErrorHandler 8 | import io.dotanuki.services.common.BlockchainInfoService 9 | import io.reactivex.schedulers.Schedulers 10 | import org.kodein.di.Kodein 11 | import org.kodein.di.generic.bind 12 | import org.kodein.di.generic.instance 13 | import org.kodein.di.generic.provider 14 | import org.kodein.di.generic.singleton 15 | 16 | val blockchainInfoModule = Kodein.Module("service-blockchainfo") { 17 | 18 | import(networkingModule) 19 | 20 | bind() from singleton { 21 | 22 | val retrofit = BuildRetrofit( 23 | apiURL = BlockchainInfo.API_URL, 24 | httpClient = instance() 25 | ) 26 | 27 | retrofit.create(BlockchainInfo::class.java) 28 | } 29 | 30 | bind() with provider { 31 | BrokerInfrastructure( 32 | targetScheduler = Schedulers.io(), 33 | service = instance(), 34 | errorHandler = ExecutionErrorHandler( 35 | logger = instance() 36 | ) 37 | ) 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /service-cache/src/main/java/io/dotanuki/services/cache/PersistantCache.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.cache 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import io.dotanuki.blockked.domain.SupportedStatistic 6 | import io.dotanuki.services.common.BitcoinStatsResponse 7 | import io.dotanuki.services.common.CacheService 8 | import kotlinx.serialization.ImplicitReflectionSerializer 9 | import kotlinx.serialization.json.JSON 10 | import kotlinx.serialization.parse 11 | import kotlinx.serialization.stringify 12 | 13 | @SuppressLint("ApplySharedPref") 14 | class PersistantCache(context: Context) : CacheService { 15 | 16 | private val prefs by lazy { 17 | context.getSharedPreferences("blockchaininfo.cache", Context.MODE_PRIVATE) 18 | } 19 | 20 | @ImplicitReflectionSerializer 21 | override fun save(key: SupportedStatistic, value: BitcoinStatsResponse) { 22 | prefs.edit() 23 | .putString(key.toString(), JSON.nonstrict.stringify(value)) 24 | .commit() 25 | } 26 | 27 | @ImplicitReflectionSerializer 28 | override fun retrieveOrNull(key: SupportedStatistic): BitcoinStatsResponse? { 29 | val target = prefs.getString(key.toString(), null) 30 | 31 | return try { 32 | JSON.nonstrict.parse(target?.let { it } ?: "{}") 33 | } catch (error: Throwable) { 34 | null 35 | } 36 | } 37 | 38 | override fun remove(key: SupportedStatistic) { 39 | prefs.edit() 40 | .remove(key.toString()) 41 | .commit() 42 | } 43 | 44 | override fun purge() { 45 | prefs.edit().clear().commit() 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/scenarioComposer.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked 2 | 3 | import com.nhaarman.mockitokotlin2.any 4 | import com.nhaarman.mockitokotlin2.whenever 5 | import io.dotanuki.blockked.domain.BitcoinStatistic 6 | import io.dotanuki.blockked.domain.FetchBitcoinStatistic 7 | import io.reactivex.Observable 8 | 9 | fun given(broker: FetchBitcoinStatistic, block: ScenarioHook.() -> Unit) = 10 | ScenarioHook(ScenarioComposer(broker)).apply { block() } 11 | 12 | class ScenarioHook(private val composer: ScenarioComposer) { 13 | 14 | fun defineScenario(setup: Scenario.() -> Unit) = 15 | Scenario(composer).apply { setup() }.configure() 16 | } 17 | 18 | class Scenario(private val composer: ScenarioComposer) { 19 | 20 | lateinit var criteria: HandledCondition 21 | 22 | fun configure() { 23 | when (criteria) { 24 | is IssueFound -> composer.fetchFailed(criteria as IssueFound) 25 | is DataFechted -> composer.marketPriceFetched(criteria as DataFechted) 26 | } 27 | } 28 | } 29 | 30 | 31 | sealed class HandledCondition 32 | class IssueFound(val error: Throwable) : HandledCondition() 33 | class DataFechted(val info: BitcoinStatistic) : HandledCondition() 34 | 35 | class ScenarioComposer(private val broker: FetchBitcoinStatistic) { 36 | 37 | fun fetchFailed(condition: IssueFound) { 38 | whenever(broker.execute(any(), any())) 39 | .thenReturn( 40 | Observable.error(condition.error) 41 | ) 42 | } 43 | 44 | 45 | fun marketPriceFetched(data: DataFechted) { 46 | whenever(broker.execute(any(), any())).thenReturn( 47 | Observable.just(data.info) 48 | ) 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /service-blockchaininfo/src/test/java/io/dotanuki/blockchainservice/tests/HandleSerializationErrorTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockchainservice.tests 2 | 3 | import io.dotanuki.blockked.domain.RemoteIntegrationIssue 4 | import io.dotanuki.logger.ConsoleLogger 5 | import io.dotanuki.service.blockchaininfo.util.HandleSerializationError 6 | import io.reactivex.Observable 7 | import kotlinx.serialization.SerializationException 8 | import labs.dotanuki.tite.checks.broken 9 | import labs.dotanuki.tite.checks.completed 10 | import labs.dotanuki.tite.checks.terminated 11 | import labs.dotanuki.tite.given 12 | import org.junit.Test 13 | 14 | class HandleSerializationErrorTests { 15 | 16 | @Test fun `should handle serialization errors`() { 17 | val parseError = SerializationException("Found comments inside this JSON") 18 | val execution = Observable.error(parseError) 19 | assertHandling(execution, RemoteIntegrationIssue.UnexpectedResponse) 20 | } 21 | 22 | 23 | @Test fun `should not handle any other errors`() { 24 | val errorToBePropagated = IllegalStateException("Something broke here ...") 25 | val execution = Observable.error(errorToBePropagated) 26 | assertHandling(execution, errorToBePropagated) 27 | } 28 | 29 | private fun assertHandling(target: Observable, expectedError: Throwable) { 30 | 31 | val handler = HandleSerializationError(ConsoleLogger) 32 | val execution = target.compose(handler) 33 | 34 | given(execution) { 35 | 36 | assertThatSequence { 37 | should notBe completed 38 | should be broken 39 | should be terminated 40 | } 41 | 42 | verifyWhenError { 43 | fails byError expectedError 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /networking/src/main/java/io/dotanuki/networking/HandleConnectivityIssue.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.networking 2 | 3 | import io.dotanuki.blockked.domain.NetworkingIssue 4 | import io.reactivex.Observable 5 | import io.reactivex.ObservableSource 6 | import io.reactivex.ObservableTransformer 7 | import java.io.IOException 8 | import java.net.ConnectException 9 | import java.net.NoRouteToHostException 10 | import java.net.SocketTimeoutException 11 | import java.net.UnknownHostException 12 | 13 | class HandleConnectivityIssue : ObservableTransformer { 14 | 15 | override fun apply(upstream: Observable): ObservableSource = 16 | upstream.onErrorResumeNext(this::handleIfNetworkingError) 17 | 18 | private fun handleIfNetworkingError(throwable: Throwable) = 19 | if (isNetworkingError(throwable)) asNetworkingError(throwable) 20 | else Observable.error(throwable) 21 | 22 | 23 | private fun asNetworkingError(throwable: Throwable) = Observable.error( 24 | mapToDomainError(throwable) 25 | ) 26 | 27 | private fun mapToDomainError(error: Throwable): NetworkingIssue { 28 | if (isConnectionTimeout(error)) return NetworkingIssue.OperationTimeout 29 | if (cannotReachHost(error)) return NetworkingIssue.HostUnreachable 30 | return NetworkingIssue.ConnectionSpike 31 | } 32 | 33 | private fun isNetworkingError(error: Throwable) = 34 | isConnectionTimeout(error) || cannotReachHost(error) || isRequestCanceled(error) 35 | 36 | private fun isRequestCanceled(throwable: Throwable) = 37 | throwable is IOException && throwable.message?.contentEquals("Canceled") ?: false 38 | 39 | private fun cannotReachHost(error: Throwable): Boolean { 40 | return error is UnknownHostException || error is ConnectException || error is NoRouteToHostException 41 | } 42 | 43 | private fun isConnectionTimeout(error: Throwable) = error is SocketTimeoutException 44 | 45 | } -------------------------------------------------------------------------------- /features-common/src/test/java/io/dotanuki/common/tests/StateMachineTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.common.tests 2 | 3 | import io.dotanuki.common.* 4 | import io.reactivex.Observable 5 | import labs.dotanuki.tite.checks.broken 6 | import labs.dotanuki.tite.checks.completed 7 | import labs.dotanuki.tite.checks.something 8 | import labs.dotanuki.tite.checks.terminated 9 | import labs.dotanuki.tite.given 10 | import org.junit.Before 11 | import org.junit.Test 12 | 13 | class StateMachineTests { 14 | 15 | lateinit var machine: StateMachine 16 | 17 | @Before fun `before each test`() { 18 | machine = StateMachine() 19 | } 20 | 21 | @Test fun `verify composition with an empty upstream`() { 22 | 23 | val noResults = Observable.empty().compose(machine) 24 | val events = listOf(Launched, Done) 25 | 26 | `assert machine execution`( 27 | incoming = noResults, 28 | expected = events 29 | ) 30 | } 31 | 32 | @Test fun `verify composition with a broken upstream`() { 33 | 34 | val failure = IllegalStateException("You failed") 35 | val errorHappened = Observable.error(failure).compose(machine) 36 | val events = listOf(Launched, Failed(failure), Done) 37 | 38 | `assert machine execution`( 39 | incoming = errorHappened, 40 | expected = events 41 | ) 42 | } 43 | 44 | @Test fun `verify composition with an successful upstream`() { 45 | 46 | val user = User("Guarilha") 47 | val execution = Observable.just(user).compose(machine) 48 | val events = listOf(Launched, Result(user), Done) 49 | 50 | `assert machine execution`( 51 | incoming = execution, 52 | expected = events 53 | ) 54 | } 55 | 56 | private fun `assert machine execution`( 57 | incoming: Observable>, 58 | expected: List>) { 59 | 60 | given(incoming) { 61 | 62 | assertThatSequence { 63 | should be terminated 64 | should be completed 65 | should notBe broken 66 | should emmit something 67 | } 68 | 69 | verifyForEmissions { 70 | items are expected 71 | } 72 | } 73 | } 74 | 75 | data class User(val name: String) 76 | } 77 | 78 | -------------------------------------------------------------------------------- /networking/src/test/java/io/dotanuki/networking/tests/HandleConnectivityIssueTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.networking.tests 2 | 3 | import io.dotanuki.blockked.domain.NetworkingIssue 4 | import io.dotanuki.blockked.domain.NetworkingIssue.* 5 | import io.dotanuki.burster.using 6 | import io.dotanuki.networking.HandleConnectivityIssue 7 | import io.reactivex.Observable 8 | import labs.dotanuki.tite.checks.broken 9 | import labs.dotanuki.tite.checks.completed 10 | import labs.dotanuki.tite.checks.terminated 11 | import labs.dotanuki.tite.given 12 | import org.junit.Test 13 | import java.io.IOException 14 | import java.net.ConnectException 15 | import java.net.NoRouteToHostException 16 | import java.net.SocketTimeoutException 17 | import java.net.UnknownHostException 18 | 19 | class HandleConnectivityIssueTests { 20 | 21 | @Test fun `should handle error when catched from proper networking exception`() { 22 | 23 | using { 24 | 25 | burst { 26 | values(UnknownHostException("No Internet"), HostUnreachable) 27 | values(ConnectException(), HostUnreachable) 28 | values(SocketTimeoutException(), OperationTimeout) 29 | values(NoRouteToHostException(), HostUnreachable) 30 | values(IOException("Canceled"), ConnectionSpike) 31 | } 32 | 33 | thenWith { incoming, expected -> 34 | val execution = Observable.error(incoming) 35 | assertHandling(execution, expected) 36 | } 37 | } 38 | 39 | } 40 | 41 | @Test fun `should not handle any other errors`() { 42 | val errorToBePropagated = IllegalStateException("Something broke here ...") 43 | val execution = Observable.error(errorToBePropagated) 44 | assertHandling(execution, errorToBePropagated) 45 | } 46 | 47 | private fun assertHandling(target: Observable, givenError: Throwable) { 48 | 49 | val handler = HandleConnectivityIssue() 50 | val execution = target.compose(handler) 51 | 52 | given(execution) { 53 | 54 | assertThatSequence { 55 | should notBe completed 56 | should be broken 57 | should be terminated 58 | } 59 | 60 | verifyWhenError { 61 | fails byError givenError 62 | } 63 | } 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /services-common/src/test/java/io/dotanuki/services/common/tests/BitcoinInfoMapperTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.common.tests 2 | 3 | import io.dotanuki.blockked.domain.BitcoinStatistic 4 | import io.dotanuki.blockked.domain.TimeBasedMeasure 5 | import io.dotanuki.services.common.BitcoinInfoMapper 6 | import io.dotanuki.services.common.BitcoinStatsResponse 7 | import io.dotanuki.services.common.StatisticPoint 8 | import org.assertj.core.api.Java6Assertions.assertThat 9 | import org.junit.Test 10 | import java.text.SimpleDateFormat 11 | import java.util.* 12 | 13 | class BitcoinInfoMapperTests { 14 | 15 | @Test fun `should map to Bitcoininfo from parsed MarketPrice model`() { 16 | 17 | val response = BitcoinStatsResponse( 18 | name = "Market Price (USD)", 19 | description = "Average USD market value across major bitcoin exchanges.", 20 | unit = "USD", 21 | values = listOf( 22 | StatisticPoint( 23 | timestamp = 1540166400, 24 | value = 6498.485833333333f 25 | ), 26 | StatisticPoint( 27 | timestamp = 1540252800, 28 | value = 6481.425999999999f 29 | ) 30 | ) 31 | ) 32 | 33 | 34 | val expected = BitcoinStatistic( 35 | providedName = "Market Price (USD)", 36 | providedDescription = "Average USD market value across major bitcoin exchanges.", 37 | unitName = "USD", 38 | measures = listOf( 39 | TimeBasedMeasure( 40 | dateTime = "2018-10-21".toDate(), 41 | value = 6498.485833333333f 42 | ), 43 | TimeBasedMeasure( 44 | dateTime = "2018-10-22".toDate(), 45 | value = 6481.425999999999f 46 | ) 47 | ) 48 | ) 49 | 50 | assertThat(BitcoinInfoMapper(response)).isEqualTo(expected) 51 | } 52 | 53 | private fun String.toDate(): Date { 54 | 55 | val formatter = SimpleDateFormat("yyyy-MM-dd") 56 | val parsed = formatter.parse(this) 57 | val calendar = Calendar.getInstance().apply { 58 | time = parsed 59 | set(Calendar.HOUR_OF_DAY, 12) 60 | set(Calendar.MINUTE, 0) 61 | set(Calendar.SECOND, 0) 62 | } 63 | 64 | return calendar.time 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | 5 | repositories { 6 | mavenCentral() 7 | maven { url 'https://jitpack.io' } 8 | } 9 | 10 | android { 11 | 12 | compileSdkVersion AndroidConfig.compileSdk 13 | testOptions.unitTests.includeAndroidResources = true 14 | 15 | def currentVersion = Versioning.getVersion() 16 | 17 | defaultConfig { 18 | applicationId AndroidConfig.applicationId 19 | minSdkVersion AndroidConfig.minSdk 20 | targetSdkVersion AndroidConfig.targetSdk 21 | versionCode currentVersion.code 22 | versionName currentVersion.name 23 | testInstrumentationRunner AndroidConfig.instrumentationTestRunner 24 | 25 | vectorDrawables.useSupportLibrary = true 26 | vectorDrawables.generatedDensities = [] 27 | 28 | archivesBaseName = "blockked-${currentVersion.name}" 29 | resConfigs "en" 30 | } 31 | 32 | compileOptions { 33 | sourceCompatibility JavaVersion.VERSION_1_8 34 | targetCompatibility JavaVersion.VERSION_1_8 35 | } 36 | 37 | kotlinOptions{ 38 | jvmTarget = '1.8' 39 | } 40 | 41 | signingConfigs { 42 | release { 43 | storeFile file("../build-system/dotanuki-demos.jks") 44 | storePassword "dotanuki" 45 | keyAlias 'dotanuki-alias' 46 | keyPassword "dotanuki" 47 | } 48 | } 49 | 50 | buildTypes { 51 | 52 | debug { 53 | minifyEnabled false 54 | lintOptions { 55 | tasks.lint.enabled = false 56 | } 57 | } 58 | 59 | release { 60 | signingConfig signingConfigs.release 61 | shrinkResources true 62 | minifyEnabled true 63 | proguardFiles file('../proguard').listFiles().toList().toArray() 64 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') 65 | } 66 | } 67 | } 68 | 69 | dependencies { 70 | 71 | implementation project(':logger') 72 | implementation project(':feature-dashboards') 73 | 74 | AndroidModule.main.forEach { implementation it } 75 | AndroidModule.unitTesting.forEach { testImplementation it } 76 | AndroidModule.androidTesting.forEach { androidTestImplementation it } 77 | 78 | androidTestImplementation project(':domain') 79 | androidTestImplementation project(':features-common') 80 | 81 | } 82 | 83 | androidExtensions { 84 | experimental = true 85 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /feature-dashboards/src/main/java/io/dotanuki/blockked/dashboards/DashboardEntry.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.dashboards 2 | 3 | import android.view.View 4 | import com.github.mikephil.charting.components.Legend 5 | import com.github.mikephil.charting.data.Entry 6 | import com.github.mikephil.charting.data.LineData 7 | import com.github.mikephil.charting.data.LineDataSet 8 | import com.github.mikephil.charting.formatter.LargeValueFormatter 9 | import com.xwray.groupie.kotlinandroidextensions.Item 10 | import com.xwray.groupie.kotlinandroidextensions.ViewHolder 11 | import kotlinx.android.synthetic.main.view_dashboard.* 12 | 13 | class DashboardEntry(private val presentation: DashboardPresentation) : Item() { 14 | 15 | override fun getLayout() = R.layout.view_dashboard 16 | 17 | override fun bind(viewHolder: ViewHolder, position: Int) = with(viewHolder) { 18 | val (display, chart) = presentation 19 | 20 | when (chart) { 21 | is ChartModel.Unavailable -> bitcoinPriceChart.visibility = View.GONE 22 | is ChartModel.AvaliableData -> { 23 | 24 | val dataSets = listOf( 25 | LineDataSet(chart.values.map { it as Entry }, chart.legend).apply { 26 | setDrawValues(false) 27 | setDrawCircles(chart.shouldDiscretize) 28 | } 29 | ) 30 | 31 | bitcoinPriceChart.apply { 32 | setPinchZoom(false) 33 | setTouchEnabled(false) 34 | description.isEnabled = false 35 | axisRight.isEnabled = false 36 | xAxis.isEnabled = false 37 | 38 | setDrawBorders(false) 39 | setDrawGridBackground(false) 40 | 41 | axisLeft.apply { 42 | setDrawZeroLine(false) 43 | axisMaximum = chart.maxValue 44 | axisMinimum = chart.minValue 45 | valueFormatter = LargeValueFormatter() 46 | } 47 | 48 | data = LineData(dataSets) 49 | 50 | legend.apply { 51 | setDrawInside(false) 52 | verticalAlignment = Legend.LegendVerticalAlignment.BOTTOM 53 | horizontalAlignment = Legend.LegendHorizontalAlignment.LEFT 54 | } 55 | } 56 | } 57 | 58 | } 59 | 60 | displayTitle.text = display.title 61 | displayValue.text = display.formattedValue 62 | displaySubtitle.text = display.subtitle 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /networking/src/test/java/io/dotanuki/networking/tests/HandleErrorByHttpStatusTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.networking.tests 2 | 3 | import io.dotanuki.blockked.domain.RemoteIntegrationIssue 4 | import io.dotanuki.blockked.domain.RemoteIntegrationIssue.* 5 | import io.dotanuki.burster.using 6 | import io.dotanuki.networking.HandleErrorByHttpStatus 7 | import io.reactivex.Observable 8 | import kotlinx.serialization.SerializationException 9 | import labs.dotanuki.tite.checks.broken 10 | import labs.dotanuki.tite.checks.completed 11 | import labs.dotanuki.tite.checks.terminated 12 | import labs.dotanuki.tite.given 13 | import okhttp3.MediaType 14 | 15 | import okhttp3.ResponseBody 16 | import org.junit.Test 17 | import retrofit2.HttpException 18 | import retrofit2.Response 19 | 20 | class HandleErrorByHttpStatusTests { 21 | 22 | @Test fun `should handle error when mapped from proper HTTP status code`() { 23 | 24 | using { 25 | 26 | burst { 27 | values(418, "Teapot", ClientOrigin) 28 | values(503, "Internal Server Error", RemoteSystem) 29 | } 30 | 31 | thenWith { httpStatus, errorMessage, mappedError -> 32 | 33 | val httpCause = httpException(httpStatus, errorMessage) 34 | val handler = HandleErrorByHttpStatus() 35 | val execution = Observable.error(httpCause).compose(handler) 36 | 37 | assertHandling(execution, mappedError) 38 | } 39 | } 40 | } 41 | 42 | @Test fun `should not handle any other errors`() { 43 | val errorToBePropagated = SerializationException("Cannot parse Data object") 44 | val execution = Observable.error(errorToBePropagated) 45 | 46 | assertHandling(execution, errorToBePropagated) 47 | } 48 | 49 | private fun assertHandling(target: Observable, expectedError: Throwable) { 50 | 51 | val handler = HandleErrorByHttpStatus() 52 | val execution = target.compose(handler) 53 | 54 | given(execution) { 55 | 56 | assertThatSequence { 57 | should notBe completed 58 | should be broken 59 | should be terminated 60 | } 61 | 62 | verifyWhenError { 63 | fails byError expectedError 64 | } 65 | } 66 | } 67 | 68 | fun httpException(statusCode: Int, errorMessage: String): HttpException { 69 | val jsonMediaType = MediaType.parse("application/json") 70 | val body = ResponseBody.create(jsonMediaType, errorMessage) 71 | return HttpException(Response.error(statusCode, body)) 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/rx2idlerktx/ScheduledWork.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.rx2idlerktx 2 | 3 | import java.util.concurrent.atomic.AtomicInteger 4 | 5 | 6 | class ScheduledWork( 7 | val target : Runnable, 8 | private val delegate: WorkDelegate, 9 | startState: Int 10 | ) : AtomicInteger(startState), Runnable { 11 | 12 | override fun toByte() = this.get().toByte() 13 | 14 | override fun toChar() = this.get().toChar() 15 | 16 | override fun toShort() = this.get().toShort() 17 | 18 | override fun run() { 19 | while (true) { 20 | val state = get() 21 | when (state) { 22 | STATE_IDLE, STATE_SCHEDULED -> if (compareAndSet(state, STATE_RUNNING)) { 23 | if (state == STATE_IDLE) { 24 | delegate.startWork() 25 | } 26 | try { 27 | target.run() 28 | } finally { 29 | // Complete with a CAS to ensure we don't overwrite a disposed state. 30 | compareAndSet(STATE_RUNNING, STATE_COMPLETED) 31 | delegate.stopWork() 32 | } 33 | return // CAS success, we're done. 34 | } 35 | 36 | STATE_RUNNING -> throw IllegalStateException("Already running") 37 | 38 | STATE_COMPLETED -> throw IllegalStateException("Already completed") 39 | 40 | STATE_DISPOSED -> return // Nothing to do. 41 | }// CAS failed, retry. 42 | } 43 | } 44 | 45 | fun dispose() { 46 | while (true) { 47 | val state = get() 48 | if (state == STATE_DISPOSED) { 49 | return // Nothing to do. 50 | } else if (compareAndSet(state, STATE_DISPOSED)) { 51 | // If idle, startWork() hasn't been called so we don't need a matching stopWork(). 52 | // If running, startWork() was called but the try/finally ensures a stopWork() call. 53 | // If completed, both startWork() and stopWork() have been called. 54 | if (state == STATE_SCHEDULED) { 55 | delegate.stopWork() // Scheduled but not running means we called startWork(). 56 | } 57 | return 58 | } 59 | } 60 | } 61 | 62 | companion object { 63 | val STATE_IDLE = 0 // --> STATE_RUNNING, STATE_DISPOSED 64 | val STATE_SCHEDULED = 1 // --> STATE_RUNNING, STATE_DISPOSED 65 | val STATE_RUNNING = 2 // --> STATE_COMPLETED, STATE_DISPOSED 66 | val STATE_COMPLETED = 3 // --> STATE_DISPOSED 67 | val STATE_DISPOSED = 4 68 | } 69 | } -------------------------------------------------------------------------------- /feature-dashboards/src/main/res/layout/activity_dashboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 16 | 17 | 28 | 29 | 30 | 31 | 36 | 37 | 46 | 47 | 48 | 49 | 50 | 61 | 62 | -------------------------------------------------------------------------------- /domain/src/main/java/io/dotanuki/blockked/domain/SupportedStatistic.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.domain 2 | 3 | sealed class SupportedStatistic { 4 | 5 | object AverageMarketPrice : SupportedStatistic() 6 | object MarketCapitalization : SupportedStatistic() 7 | object TotalBitcoins : SupportedStatistic() 8 | object TradeVolume : SupportedStatistic() 9 | 10 | object BlockchainSize : SupportedStatistic() 11 | object AverageBlockSize : SupportedStatistic() 12 | object OrphanBlocks : SupportedStatistic() 13 | object TransactionsPerBlock : SupportedStatistic() 14 | object TransactionConfirmationTime : SupportedStatistic() 15 | 16 | object HashRate : SupportedStatistic() 17 | object Difficulty : SupportedStatistic() 18 | object TotalTransactionFee : SupportedStatistic() 19 | object PercentualCostOfTransaction : SupportedStatistic() 20 | object CostPerTransaction : SupportedStatistic() 21 | 22 | object TransactionsPerDay : SupportedStatistic() 23 | object MemoryPoolSize : SupportedStatistic() 24 | object OutputVolume : SupportedStatistic() 25 | object EstimatedTransactionsVolume : SupportedStatistic() 26 | 27 | override fun toString() = when (this) { 28 | AverageMarketPrice -> "market-price" 29 | MarketCapitalization -> "market-cap" 30 | TotalBitcoins -> "total-bitcoins" 31 | TradeVolume -> "trade-volume" 32 | BlockchainSize -> "blocks-size" 33 | AverageBlockSize -> "avg-block-size" 34 | OrphanBlocks -> "n-orphaned-blocks" 35 | TransactionsPerBlock -> "n-transactions-per-block" 36 | TransactionConfirmationTime -> "median-confirmation-time" 37 | HashRate -> "hash-rate" 38 | Difficulty -> "difficulty" 39 | TotalTransactionFee -> "transaction-fees-usd" 40 | PercentualCostOfTransaction -> "cost-per-transaction-percent" 41 | CostPerTransaction -> "cost-per-transaction" 42 | TransactionsPerDay -> "n-transactions" 43 | MemoryPoolSize -> "mempool-size" 44 | OutputVolume -> "output-volume" 45 | EstimatedTransactionsVolume -> "estimated-transaction-volume-usd" 46 | } 47 | 48 | companion object { 49 | val ALL = listOf( 50 | SupportedStatistic.AverageMarketPrice, 51 | MarketCapitalization, 52 | TotalBitcoins, 53 | TradeVolume, 54 | BlockchainSize, 55 | AverageBlockSize, 56 | OrphanBlocks, 57 | TransactionsPerBlock, 58 | TransactionConfirmationTime, 59 | HashRate, 60 | Difficulty, 61 | TotalTransactionFee, 62 | PercentualCostOfTransaction, 63 | CostPerTransaction, 64 | TransactionsPerDay, 65 | MemoryPoolSize, 66 | OutputVolume, 67 | EstimatedTransactionsVolume 68 | ) 69 | } 70 | } -------------------------------------------------------------------------------- /feature-dashboards/src/test/java/io/dotanuki/blockked/dashboards/tests/DashboardViewModelTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.dashboards.tests 2 | 3 | import com.nhaarman.mockitokotlin2.mock 4 | import com.nhaarman.mockitokotlin2.whenever 5 | import io.dotanuki.blockked.dashboards.BuildDashboardPresentation 6 | import io.dotanuki.blockked.dashboards.DashboardViewModel 7 | import io.dotanuki.blockked.domain.BitcoinStatistic 8 | import io.dotanuki.blockked.domain.NetworkingIssue 9 | import io.dotanuki.blockked.domain.RetrieveStatistics 10 | import io.dotanuki.blockked.domain.TimeBasedMeasure 11 | import io.dotanuki.common.* 12 | import io.reactivex.Observable 13 | import labs.dotanuki.tite.checks.completed 14 | import labs.dotanuki.tite.given 15 | import org.junit.Before 16 | import org.junit.Test 17 | 18 | class DashboardViewModelTests { 19 | 20 | lateinit var viewModel: DashboardViewModel 21 | 22 | val mockedFetcher = mock() 23 | 24 | val statistic = BitcoinStatistic( 25 | providedName = "Market Price (USD)", 26 | providedDescription = "Average USD market value across major bitcoin exchanges.", 27 | unitName = "USD", 28 | measures = listOf( 29 | TimeBasedMeasure( 30 | dateTime = "2018-10-21T22:00:00".toDate(), 31 | value = 6498.485833333333f 32 | ) 33 | ) 34 | ) 35 | 36 | @Before fun `before each test`() { 37 | viewModel = DashboardViewModel( 38 | usecase = mockedFetcher, 39 | machine = StateMachine() 40 | ) 41 | } 42 | 43 | @Test fun `should emmit states for successful dashboard presentation`() { 44 | 45 | val provided = listOf(statistic) 46 | val expected = BuildDashboardPresentation(provided) 47 | 48 | whenever(mockedFetcher.execute()) 49 | .thenReturn( 50 | Observable.just(provided) 51 | ) 52 | 53 | given(viewModel.retrieveDashboard()) { 54 | 55 | assertThatSequence { 56 | should be completed 57 | } 58 | 59 | verifyForEmissions { 60 | items match sequenceOf( 61 | Launched, 62 | Result(expected), 63 | Done 64 | ) 65 | } 66 | } 67 | } 68 | 69 | @Test fun `should emmit states for errored broking integration`() { 70 | whenever(mockedFetcher.execute()) 71 | .thenReturn( 72 | Observable.error>(NetworkingIssue.ConnectionSpike) 73 | ) 74 | 75 | given(viewModel.retrieveDashboard()) { 76 | 77 | assertThatSequence { 78 | should be completed 79 | } 80 | 81 | verifyForEmissions { 82 | items match sequenceOf( 83 | Launched, 84 | Failed(NetworkingIssue.ConnectionSpike), 85 | Done 86 | ) 87 | } 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /feature-dashboards/src/main/res/layout/view_dashboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | 19 | 20 | 30 | 31 | 42 | 43 | 59 | 60 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BLOCKKED 2 | 3 | Blockked logo 5 | 6 | > An Android companion for blockchain.info, written in Kotlin for demo purposes 7 | 8 | 9 | ## Overview 10 | 11 | This project is a simple demo for retrieving some Blockchain.info data and presenting dashboards with latest Bitcon statistics, such average prices, mempool size, hashrates and so on. 12 | 13 | 14 | This app has support for both portrait and landscape modes, as well offline caching. This project is HEAVILY tested, and I think that it is a showcase on how we can achieve great level of confidence based on good architectural decisions. 15 | 16 | This project is 100% written in Kotlin. 17 | 18 | ![Blockked Screenshot](.github/blockked-screenshot.png) 19 | 20 | ## Knowledge Stack 21 | 22 | This project leverages on 23 | 24 | - 100% powered by AndroidX (no Jetfier!) 25 | - RxJava2 for end-to-end reactive programming 26 | - Kodein for Dependency Injection 27 | - Kotlinx.Serialization for automatic JSON handling 28 | - OkHttp3 + Retrofit for networking over HTTP 29 | - D8/R8 for desugaring / shrinking / optmizing 30 | - Some custom libraries made by myself for testing 31 | - Fancy DSLs 32 | - Several tricks 33 | 34 | 35 | ## Building and Running 36 | 37 | ### Running from IDE 38 | 39 | - Ensure you have Android Studio 3.3 canary or newer 40 | 41 | ### Building from CLI 42 | 43 | To run all unit tests and build an APK, execute 44 | 45 | ``` 46 | ./gradlew build 47 | ``` 48 | 49 | ### Running integration tests 50 | 51 | To run acceptance tests powered by Instrumentation + Espresso, execute 52 | 53 | ``` 54 | ./gradlew connectedCheck 55 | ``` 56 | 57 | ## Credits 58 | 59 | Special thanks for 60 | 61 | - [Blockchain.com](https://blockchain.com) for the public Bitcoin API 62 | - [The Noun Project](https://thenounproject.com) for being such amazing service 63 | - [Pablo Rosenberg](https://thenounproject.com/pabslabs) for the app icon 64 | 65 | 66 | ## LICENSE 67 | 68 | ``` 69 | The MIT License (MIT) 70 | 71 | Copyright (c) 2018 Ubiratan Soares 72 | 73 | Permission is hereby granted, free of charge, to any person obtaining a copy of 74 | this software and associated documentation files (the "Software"), to deal in 75 | the Software without restriction, including without limitation the rights to 76 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 77 | the Software, and to permit persons to whom the Software is furnished to do so, 78 | subject to the following conditions: 79 | 80 | The above copyright notice and this permission notice shall be included in all 81 | copies or substantial portions of the Software. 82 | 83 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 84 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 85 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 86 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 87 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 88 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 89 | ``` -------------------------------------------------------------------------------- /feature-dashboards/src/main/java/io/dotanuki/blockked/dashboards/BuildDashboardPresentation.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.dashboards 2 | 3 | import io.dotanuki.blockked.domain.BitcoinStatistic 4 | import io.dotanuki.blockked.domain.TimeBasedMeasure 5 | import java.text.NumberFormat 6 | import java.text.SimpleDateFormat 7 | import java.util.* 8 | 9 | object BuildDashboardPresentation { 10 | 11 | operator fun invoke(info: List) = info.map { 12 | DashboardPresentation( 13 | 14 | display = DisplayModel( 15 | title = it.providedName, 16 | subtitle = it.providedDescription, 17 | formattedValue = formatValue(it.measures.last(), it.unitName) 18 | ), 19 | 20 | chart = assembleChart(it) 21 | ) 22 | } 23 | 24 | private fun assembleChart(info: BitcoinStatistic) = with(info) { 25 | if (measures.size < 2) ChartModel.Unavailable 26 | else { 27 | ChartModel.AvaliableData( 28 | shouldDiscretize = measures.size <= BARELY_ONE_POINT_PER_DAY, 29 | minValue = extractMinimum(measures), 30 | maxValue = extractMaximum(measures), 31 | legend = formatLegend(measures.first(), measures.last()), 32 | values = buildEntries(measures) 33 | ) 34 | } 35 | } 36 | 37 | private fun buildEntries(prices: List) = 38 | prices.mapIndexed { index, bitcoinPrice -> 39 | val x = (index + 1).toFloat() 40 | PlottableEntry(x, bitcoinPrice.value) 41 | } 42 | 43 | private fun formatLegend(first: TimeBasedMeasure, last: TimeBasedMeasure) = 44 | "Data sampled, from ${formateDate(first.dateTime)} to ${formateDate(last.dateTime)}" 45 | 46 | private fun extractMaximum(prices: List) = 47 | prices.asSequence().map { it.value }.max() 48 | ?.let { if (it == 0.0f) 10.0f else it + it * 0.05f } 49 | ?: throw IllegalArgumentException("No maximum") 50 | 51 | private fun extractMinimum(prices: List) = 52 | prices.asSequence().map { it.value }.min() 53 | ?.let { if (it == 0.0f) -10.0f else it - it * 0.05f } 54 | ?: throw IllegalArgumentException("No minimum") 55 | 56 | private fun formatValue(last: TimeBasedMeasure, unitName: String) = 57 | with(last) { 58 | when (unitName) { 59 | "USD", "Trade Volume (USD)" -> priceFormatter.format(value) 60 | "Transactions Per Block" -> "${numberFormatter.format(value)} transactions" 61 | "Hash Rate TH/s" -> "${numberFormatter.format(value)} TeraHashes/s" 62 | else -> "${numberFormatter.format(value)} $unitName" 63 | } 64 | } 65 | 66 | private fun formateDate(target: Date) = dateFormatter.format(target) 67 | 68 | private val priceFormatter = NumberFormat.getCurrencyInstance(Locale.US) 69 | private val numberFormatter = NumberFormat.getNumberInstance() 70 | private val dateFormatter = SimpleDateFormat.getDateInstance() 71 | 72 | private val BARELY_ONE_POINT_PER_DAY = 31 73 | 74 | } -------------------------------------------------------------------------------- /feature-dashboards/src/main/res/layout-land/view_dashboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 20 | 21 | 27 | 28 | 36 | 37 | 48 | 49 | 63 | 64 | 65 | 66 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /feature-dashboards/src/test/java/io/dotanuki/blockked/dashboards/tests/BuildDashboardPresentationTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.dashboards.tests 2 | 3 | import io.dotanuki.blockked.dashboards.* 4 | import io.dotanuki.blockked.domain.BitcoinStatistic 5 | import io.dotanuki.blockked.domain.TimeBasedMeasure 6 | import io.dotanuki.common.toDate 7 | import org.assertj.core.api.Java6Assertions.assertThat 8 | import org.junit.Test 9 | 10 | class BuildDashboardPresentationTests { 11 | 12 | @Test fun `should build available chart data from bitcoin info`() { 13 | 14 | val provided = BitcoinStatistic( 15 | providedName = "Market Price (USD)", 16 | providedDescription = "Average USD market value across major bitcoin exchanges.", 17 | unitName = "USD", 18 | measures = listOf( 19 | TimeBasedMeasure( 20 | dateTime = "2018-10-21T22:00:00".toDate(), 21 | value = 6498.485833333333f 22 | ), 23 | TimeBasedMeasure( 24 | dateTime = "2018-10-22T22:00:00".toDate(), 25 | value = 6481.425999999999f 26 | ), 27 | TimeBasedMeasure( 28 | dateTime = "2018-10-23T22:00:00".toDate(), 29 | value = 6511.321999999999f 30 | ) 31 | ) 32 | ) 33 | 34 | 35 | val expected = DashboardPresentation( 36 | 37 | display = DisplayModel( 38 | formattedValue = "$6,511.32", 39 | title = "Market Price (USD)", 40 | subtitle = "Average USD market value across major bitcoin exchanges." 41 | ), 42 | 43 | chart = ChartModel.AvaliableData( 44 | minValue = 6481.425999999999f - 6481.425999999999f * 0.05f, 45 | maxValue = 6511.321999999999f + 6511.321999999999f * 0.05f, 46 | legend = "Data sampled, from Oct 21, 2018 to Oct 23, 2018", 47 | values = listOf( 48 | PlottableEntry(1.0f, 6498.485833333333f), 49 | PlottableEntry(2.0f, 6481.425999999999f), 50 | PlottableEntry(3.0f, 6511.321999999999f) 51 | ) 52 | ) 53 | ) 54 | 55 | val statistics = listOf(provided) 56 | val presentations = listOf(expected) 57 | 58 | assertThat(BuildDashboardPresentation(statistics)).isEqualTo(presentations) 59 | } 60 | 61 | @Test fun `should build display only, when chart info missing`() { 62 | 63 | val provided = BitcoinStatistic( 64 | providedName = "Market Price (USD)", 65 | providedDescription = "Average USD market value across major bitcoin exchanges.", 66 | unitName = "USD", 67 | measures = listOf( 68 | TimeBasedMeasure( 69 | dateTime = "2018-10-21T22:00:00".toDate(), 70 | value = 6498.485833333333f 71 | ) 72 | ) 73 | ) 74 | 75 | val expected = DashboardPresentation( 76 | 77 | display = DisplayModel( 78 | formattedValue = "$6,498.49", 79 | title = "Market Price (USD)", 80 | subtitle = "Average USD market value across major bitcoin exchanges." 81 | ), 82 | 83 | chart = ChartModel.Unavailable 84 | ) 85 | 86 | 87 | val statistics = listOf(provided) 88 | val presentations = listOf(expected) 89 | 90 | assertThat(BuildDashboardPresentation(statistics)).isEqualTo(presentations) 91 | 92 | } 93 | 94 | 95 | } -------------------------------------------------------------------------------- /service-blockchaininfo/src/test/java/io/dotanuki/blockchainservice/tests/BlockchainInfoInfrastructureTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockchainservice.tests 2 | 3 | import io.dotanuki.blockchainservice.tests.util.InfrastructureRule 4 | import io.dotanuki.blockchainservice.tests.util.loadFile 5 | import io.dotanuki.blockked.domain.RemoteIntegrationIssue 6 | import io.dotanuki.blockked.domain.RemoteIntegrationIssue.* 7 | import io.dotanuki.blockked.domain.SupportedStatistic 8 | import io.dotanuki.burster.using 9 | import io.dotanuki.logger.ConsoleLogger 10 | import io.dotanuki.service.blockchaininfo.BrokerInfrastructure 11 | import io.dotanuki.service.blockchaininfo.ExecutionErrorHandler 12 | import io.dotanuki.services.common.BitcoinStatsResponse 13 | import io.dotanuki.services.common.StatisticPoint 14 | import labs.dotanuki.tite.checks.broken 15 | import labs.dotanuki.tite.checks.completed 16 | import labs.dotanuki.tite.checks.nothing 17 | import labs.dotanuki.tite.checks.something 18 | import labs.dotanuki.tite.given 19 | import org.junit.Before 20 | import org.junit.Rule 21 | import org.junit.Test 22 | 23 | internal class BlockchainInfoInfrastructureTests { 24 | 25 | @get:Rule val rule = InfrastructureRule() 26 | 27 | lateinit var infrastructure: BrokerInfrastructure 28 | 29 | @Before fun `before each test`() { 30 | infrastructure = BrokerInfrastructure( 31 | service = rule.api, 32 | errorHandler = ExecutionErrorHandler(ConsoleLogger) 33 | ) 34 | } 35 | 36 | @Test fun `should retrieve Bitcoin market price with success`() { 37 | 38 | rule.defineScenario( 39 | status = 200, 40 | response = loadFile("200OK-market-price.json") 41 | ) 42 | 43 | val expected = BitcoinStatsResponse( 44 | name = "Market Price (USD)", 45 | description = "Average USD market value across major bitcoin exchanges.", 46 | unit = "USD", 47 | values = listOf( 48 | StatisticPoint( 49 | timestamp = 1540166400, 50 | value = 6498f 51 | ), 52 | StatisticPoint( 53 | timestamp = 1540252800, 54 | value = 6481f 55 | ) 56 | ) 57 | ) 58 | 59 | given(infrastructure.fetchStatistics(SupportedStatistic.AverageMarketPrice)) { 60 | 61 | assertThatSequence { 62 | should be completed 63 | should emmit something 64 | } 65 | 66 | verifyForEmissions { 67 | firstItem shouldBe expected 68 | } 69 | } 70 | } 71 | 72 | @Test fun `should map issue for non-desired responses`() { 73 | 74 | 75 | using { 76 | 77 | burst { 78 | values("200OK-market-price-broken.json", 200, UnexpectedResponse) 79 | values("404-not-found.json", 404, ClientOrigin) 80 | values("503-internal-server.json", 503, RemoteSystem) 81 | } 82 | 83 | thenWith { json, statusCode, expectedIssue -> 84 | 85 | rule.defineScenario( 86 | status = statusCode, 87 | response = loadFile(json) 88 | ) 89 | 90 | given(infrastructure.fetchStatistics(SupportedStatistic.AverageMarketPrice)) { 91 | 92 | assertThatSequence { 93 | should be broken 94 | should emmit nothing 95 | } 96 | 97 | verifyWhenError { 98 | fails byError expectedIssue 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/dashboardVerifications.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked 2 | 3 | import androidx.test.espresso.Espresso.onView 4 | import androidx.test.espresso.assertion.ViewAssertions.matches 5 | import androidx.test.espresso.matcher.ViewMatchers.* 6 | import io.dotanuki.blockked.SwipeRefreshLayoutMatchers.isRefreshing 7 | import org.hamcrest.BaseMatcher 8 | import org.hamcrest.Description 9 | import org.hamcrest.Matcher 10 | import org.hamcrest.Matchers.not 11 | 12 | 13 | fun assertThat(block: DashboardVerifications.() -> Unit) = 14 | DashboardVerifications().apply { block() } 15 | 16 | class DashboardVerifications { 17 | 18 | fun loadingIndicator(block: LoadingStateVerifier.() -> Unit) = 19 | LoadingStateVerifier().apply { block() } 20 | 21 | fun errorReport(block: ErrorStateVerifier.() -> Unit) = 22 | ErrorStateVerifier().apply { block() } 23 | 24 | fun dashboard(block: DashboardContentVerifier.() -> Unit) = 25 | DashboardContentVerifier().apply { block() } 26 | } 27 | 28 | class LoadingStateVerifier { 29 | val should by lazy { LoadingStateChecks() } 30 | } 31 | 32 | class LoadingStateChecks { 33 | 34 | infix fun be(target: Visibility) = when (target) { 35 | is displayedWith -> checkDisplayed(target.message) 36 | is displayed -> checkRefreshing() 37 | is hidden -> checkNotRefreshing() 38 | } 39 | } 40 | 41 | class ErrorStateVerifier { 42 | val should by lazy { ErrorStateChecks() } 43 | } 44 | 45 | class DashboardContentVerifier { 46 | val should by lazy { DashboardContentCheck() } 47 | } 48 | 49 | class ErrorStateChecks { 50 | 51 | infix fun be(target: Visibility) = when (target) { 52 | is displayedWith -> checkDisplayed(target.message) 53 | is displayed -> checkDisplayed(R.id.errorStateLabel) 54 | is hidden -> checkHidden(R.id.errorStateLabel) 55 | } 56 | } 57 | 58 | class DashboardContentCheck { 59 | 60 | infix fun have(content: DashboardContent) = when (content) { 61 | 62 | is noEntries -> checkHidden(R.id.dashboarView) 63 | 64 | is OnlyDisplay -> checkDisplayed(content.bitcoinValue) 65 | 66 | is DisplayAndGraph -> { 67 | checkDisplayed(R.id.dashboarView) 68 | checkDisplayed(content.bitcoinValue) 69 | } 70 | } 71 | 72 | } 73 | 74 | 75 | sealed class Visibility 76 | object hidden : Visibility() 77 | object displayed : Visibility() 78 | class displayedWith(val message: String) : Visibility() 79 | 80 | sealed class DashboardContent 81 | data class OnlyDisplay(val bitcoinValue: String) : DashboardContent() 82 | class DisplayAndGraph(val bitcoinValue: String) : DashboardContent() 83 | object noEntries : DashboardContent() 84 | 85 | private fun checkDisplayed(target: String) { 86 | onView(firstViewOf(withText(target))).check(matches(isDisplayed())) 87 | } 88 | 89 | private fun checkDisplayed(target: Int) { 90 | onView(firstViewOf(withId(target))) 91 | .check(matches(isDisplayed())) 92 | } 93 | 94 | private fun checkHidden(target: String) { 95 | onView(firstViewOf(withText(target))) 96 | .check(matches(not(isDisplayed()))) 97 | } 98 | 99 | private fun checkHidden(target: Int) { 100 | onView(firstViewOf(withId(target))) 101 | .check(matches(not(isDisplayed()))) 102 | } 103 | 104 | private fun checkNotRefreshing() { 105 | onView(withId(R.id.swipeToRefresh)) 106 | .check(matches(not(isRefreshing()))) 107 | } 108 | 109 | private fun checkRefreshing() { 110 | onView(withId(R.id.swipeToRefresh)) 111 | .check(matches(isRefreshing())) 112 | } 113 | 114 | private fun firstViewOf(matcher: Matcher): Matcher { 115 | return object : BaseMatcher() { 116 | private var isFirst = true 117 | 118 | override fun matches(item: Any): Boolean { 119 | if (isFirst && matcher.matches(item)) { 120 | isFirst = false 121 | return true 122 | } 123 | return false 124 | } 125 | 126 | override fun describeTo(description: Description) { 127 | description.appendText("should return first matching item") 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /feature-dashboards/src/main/java/io/dotanuki/blockked/dashboards/DashboardActivity.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.dashboards 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.core.content.ContextCompat 7 | import androidx.recyclerview.widget.LinearLayoutManager 8 | import com.google.android.material.snackbar.Snackbar 9 | import com.xwray.groupie.GroupAdapter 10 | import com.xwray.groupie.ViewHolder 11 | import io.dotanuki.blockked.domain.NetworkingIssue 12 | import io.dotanuki.blockked.domain.RemoteIntegrationIssue 13 | import io.dotanuki.common.* 14 | import io.dotanuki.logger.Logger 15 | import io.reactivex.rxkotlin.subscribeBy 16 | import kotlinx.android.synthetic.main.activity_dashboard.* 17 | import org.kodein.di.Kodein 18 | import org.kodein.di.KodeinAware 19 | import org.kodein.di.android.closestKodein 20 | import org.kodein.di.generic.instance 21 | 22 | class DashboardActivity : AppCompatActivity(), KodeinAware { 23 | 24 | private val graph by closestKodein() 25 | 26 | override val kodein = Kodein.lazy { 27 | extend(graph) 28 | } 29 | 30 | private val logger by kodein.instance() 31 | private val disposer by kodein.instance() 32 | private val viewModel by kodein.instance() 33 | 34 | private val dashboardsAdapter by lazy { 35 | GroupAdapter() 36 | } 37 | 38 | override fun onCreate(savedInstanceState: Bundle?) { 39 | super.onCreate(savedInstanceState) 40 | setContentView(R.layout.activity_dashboard) 41 | setupViews() 42 | loadDashboard() 43 | lifecycle.addObserver(disposer) 44 | } 45 | 46 | private fun setupViews() { 47 | dashboarView.apply { 48 | layoutManager = LinearLayoutManager(this@DashboardActivity) 49 | adapter = dashboardsAdapter 50 | } 51 | 52 | swipeToRefresh.apply { 53 | setColorSchemeColors(ContextCompat.getColor(this@DashboardActivity, R.color.colorPrimary)) 54 | setOnRefreshListener { loadDashboard() } 55 | } 56 | 57 | setSupportActionBar(toolbar) 58 | } 59 | 60 | private fun loadDashboard() { 61 | val toDispose = viewModel 62 | .retrieveDashboard() 63 | .subscribeBy( 64 | onNext = { changeState(it) }, 65 | onError = { logger.e("Error -> $it") } 66 | ) 67 | 68 | disposer.collect(toDispose) 69 | } 70 | 71 | private fun changeState(event: UIEvent>) { 72 | when (event) { 73 | is Launched -> startExecution() 74 | is Result -> presentDashboard(event.value) 75 | is Failed -> reportError(event.reason) 76 | is Done -> finishExecution() 77 | } 78 | } 79 | 80 | private fun reportError(reason: Throwable) { 81 | logger.e("Error -> $reason") 82 | 83 | when (reason) { 84 | is NetworkingIssue, 85 | is RemoteIntegrationIssue -> { 86 | presentError(reason.toString()) 87 | } 88 | } 89 | } 90 | 91 | private fun presentError(message: String) { 92 | if (dashboardsAdapter.itemCount == 0) { 93 | errorStateLabel.apply { 94 | text = message 95 | visibility = View.VISIBLE 96 | } 97 | } 98 | 99 | Snackbar.make(dashboardsRoot, message, Snackbar.LENGTH_LONG).show() 100 | } 101 | 102 | private fun presentDashboard(dashboards: List) { 103 | logger.i("Loaded Dashboards") 104 | val entries = dashboards.map { DashboardEntry(it) } 105 | 106 | dashboarView.visibility = View.VISIBLE 107 | dashboardsAdapter.apply { 108 | clear() 109 | addAll(entries) 110 | } 111 | } 112 | 113 | private fun startExecution() { 114 | dashboarView.visibility = View.INVISIBLE 115 | swipeToRefresh.isRefreshing = true 116 | errorStateLabel.visibility = View.GONE 117 | 118 | } 119 | 120 | private fun finishExecution() { 121 | swipeToRefresh.isRefreshing = false 122 | } 123 | 124 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/rx2idlerktx/DelegatingIdlingResourceScheduler.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked.rx2idlerktx 2 | 3 | import androidx.test.espresso.IdlingResource 4 | import io.reactivex.Scheduler 5 | import io.reactivex.disposables.CompositeDisposable 6 | import io.reactivex.disposables.Disposable 7 | import io.reactivex.disposables.Disposables 8 | import java.util.concurrent.TimeUnit 9 | import java.util.concurrent.atomic.AtomicInteger 10 | 11 | 12 | class DelegatingIdlingResourceScheduler( 13 | private val delegate: Scheduler, 14 | private val name: String 15 | ) : IdlingResourceScheduler() { 16 | 17 | private val work = AtomicInteger() 18 | private var callback: IdlingResource.ResourceCallback? = null 19 | 20 | private val workDelegate = object : WorkDelegate { 21 | 22 | override fun startWork() { 23 | work.incrementAndGet(); 24 | } 25 | 26 | override fun stopWork() { 27 | if (work.decrementAndGet() == 0) { 28 | callback?.onTransitionToIdle(); 29 | } 30 | } 31 | 32 | } 33 | 34 | override fun getName() = name 35 | 36 | override fun isIdleNow() = work.get() == 0 37 | 38 | override fun registerIdleTransitionCallback(target: IdlingResource.ResourceCallback) { 39 | callback = target 40 | } 41 | 42 | override fun createWorker(): Scheduler.Worker { 43 | val delegateWorker = delegate.createWorker() 44 | 45 | return object : Scheduler.Worker() { 46 | 47 | private val disposables = CompositeDisposable(delegateWorker) 48 | 49 | override fun schedule(action: Runnable): Disposable { 50 | if (disposables.isDisposed) { 51 | return Disposables.disposed() 52 | } 53 | val work = createWork(action, 0L) 54 | val disposable = delegateWorker.schedule(work) 55 | val workDisposable = ScheduledWorkDisposable(work, disposable) 56 | disposables.add(workDisposable) 57 | return workDisposable 58 | } 59 | 60 | override fun schedule(action: Runnable, delayTime: Long, unit: TimeUnit): Disposable { 61 | if (disposables.isDisposed) { 62 | return Disposables.disposed() 63 | } 64 | val work = createWork(action, delayTime) 65 | val disposable = delegateWorker.schedule(work, delayTime, unit) 66 | disposables.add(disposable) 67 | val workDisposable = ScheduledWorkDisposable(work, disposable) 68 | disposables.add(workDisposable) 69 | return workDisposable 70 | } 71 | 72 | override fun schedulePeriodically( 73 | action: Runnable, initialDelay: Long, period: Long, 74 | unit: TimeUnit 75 | ): Disposable { 76 | if (disposables.isDisposed) { 77 | return Disposables.disposed() 78 | } 79 | val work = createWork(action, initialDelay) 80 | val disposable = delegateWorker.schedulePeriodically(work, initialDelay, period, unit) 81 | disposables.add(disposable) 82 | val workDisposable = ScheduledWorkDisposable(work, disposable) 83 | disposables.add(workDisposable) 84 | return workDisposable 85 | } 86 | 87 | override fun dispose() { 88 | disposables.dispose() 89 | } 90 | 91 | override fun isDisposed(): Boolean { 92 | return disposables.isDisposed 93 | } 94 | } 95 | } 96 | 97 | fun createWork(incoming: Runnable, delay: Long): ScheduledWork { 98 | var action = incoming 99 | if (action is ScheduledWork) { 100 | // Unwrap any re-scheduled work. We want each scheduler to get its own state machine. 101 | action = action.target 102 | } 103 | val immediate = delay == 0L 104 | if (immediate) { 105 | workDelegate.startWork() 106 | } 107 | val startingState = if (immediate) ScheduledWork.STATE_SCHEDULED else ScheduledWork.STATE_IDLE 108 | return ScheduledWork(action, workDelegate, startingState) 109 | } 110 | 111 | 112 | } -------------------------------------------------------------------------------- /services-meshing/src/test/java/io/dotanuki/services/mesh/tests/FetcherStrategistTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.services.mesh.tests 2 | 3 | import com.nhaarman.mockitokotlin2.* 4 | import io.dotanuki.blockked.domain.FetchStrategy 5 | import io.dotanuki.blockked.domain.SupportedStatistic 6 | import io.dotanuki.services.common.* 7 | import io.dotanuki.services.mesh.FetcherStrategist 8 | import io.reactivex.Observable 9 | import labs.dotanuki.tite.checks.completed 10 | import labs.dotanuki.tite.checks.nothing 11 | import labs.dotanuki.tite.given 12 | import org.assertj.core.api.Assertions.assertThat 13 | import org.junit.Before 14 | import org.junit.Test 15 | 16 | class FetcherStrategistTests { 17 | 18 | private val remote = mock() 19 | private val local = mock() 20 | 21 | lateinit var fetcher: FetcherStrategist 22 | 23 | @Before fun `before each test`() { 24 | fetcher = FetcherStrategist(remote, local) 25 | } 26 | 27 | @Test fun `should fetch from local cache, with cache hit`() { 28 | `cache has previous data`() 29 | 30 | val execution = fetcher.execute( 31 | SupportedStatistic.AverageMarketPrice, 32 | FetchStrategy.FromPrevious 33 | ) 34 | 35 | val mapped = BitcoinInfoMapper(PREVIOUSLY_CACHED) 36 | 37 | given(execution) { 38 | assertThatSequence { 39 | should be completed 40 | } 41 | 42 | verifyForEmissions { 43 | firstItem shouldBe mapped 44 | } 45 | } 46 | 47 | verify(local, times(1)).retrieveOrNull(any()) 48 | verifyNoMoreInteractions(local) 49 | verifyZeroInteractions(remote) 50 | } 51 | 52 | @Test fun `should fetch from local cache, with cache miss`() { 53 | `cache has no previous data`() 54 | 55 | val execution = fetcher.execute( 56 | SupportedStatistic.AverageMarketPrice, 57 | FetchStrategy.FromPrevious 58 | ) 59 | 60 | 61 | given(execution) { 62 | assertThatSequence { 63 | should be completed 64 | should emmit nothing 65 | } 66 | } 67 | 68 | verify(local, times(1)).retrieveOrNull(any()) 69 | verifyNoMoreInteractions(local) 70 | verifyZeroInteractions(remote) 71 | } 72 | 73 | @Test fun `should fetch from remote service, updating local cache`() { 74 | `remote exposes updated data`() 75 | 76 | val execution = fetcher.execute( 77 | SupportedStatistic.AverageMarketPrice, 78 | FetchStrategy.ForceUpdate 79 | ) 80 | 81 | val mapped = BitcoinInfoMapper(UPDATED) 82 | 83 | given(execution) { 84 | assertThatSequence { 85 | should be completed 86 | } 87 | 88 | verifyForEmissions { 89 | firstItem shouldBe mapped 90 | } 91 | } 92 | 93 | verify(remote, times(1)).fetchStatistics(any()) 94 | verifyNoMoreInteractions(remote) 95 | 96 | argumentCaptor().apply { 97 | verify(local, times(1)).save(any(), capture()) 98 | assertThat(firstValue).isEqualTo(UPDATED) 99 | } 100 | 101 | verifyNoMoreInteractions(local) 102 | } 103 | 104 | private fun `remote exposes updated data`() { 105 | whenever(remote.fetchStatistics(any())) 106 | .thenReturn( 107 | Observable.just(UPDATED) 108 | ) 109 | } 110 | 111 | private fun `cache has previous data`() { 112 | whenever(local.retrieveOrNull(any())) 113 | .thenReturn(PREVIOUSLY_CACHED) 114 | } 115 | 116 | 117 | private fun `cache has no previous data`() { 118 | whenever(local.retrieveOrNull(any())) 119 | .thenReturn(null) 120 | } 121 | 122 | 123 | private companion object { 124 | val PREVIOUSLY_CACHED = BitcoinStatsResponse( 125 | name = "Market Price (USD)", 126 | description = "Average USD market value across major bitcoin exchanges.", 127 | unit = "USD", 128 | values = listOf( 129 | StatisticPoint( 130 | timestamp = 1540166400, 131 | value = 6498f 132 | ), 133 | StatisticPoint( 134 | timestamp = 1540252800, 135 | value = 6481f 136 | ) 137 | ) 138 | ) 139 | 140 | val UPDATED = BitcoinStatsResponse( 141 | name = "Market Price (USD)", 142 | description = "Average USD market value across major bitcoin exchanges.", 143 | unit = "USD", 144 | values = listOf( 145 | StatisticPoint( 146 | timestamp = 1540166400, 147 | value = 6498f 148 | ), 149 | StatisticPoint( 150 | timestamp = 1540252800, 151 | value = 6481f 152 | ), 153 | StatisticPoint( 154 | timestamp = 1540253400, 155 | value = 6500f 156 | ) 157 | ) 158 | ) 159 | } 160 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/io/dotanuki/blockked/DashboardAcceptanceTests.kt: -------------------------------------------------------------------------------- 1 | package io.dotanuki.blockked 2 | 3 | import androidx.lifecycle.Lifecycle.State.RESUMED 4 | import androidx.test.core.app.launchActivity 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import com.nhaarman.mockitokotlin2.mock 7 | import io.dotanuki.blockked.dashboards.DashboardActivity 8 | import io.dotanuki.blockked.domain.* 9 | import io.dotanuki.blockked.rules.BindingsOverwriter 10 | import io.dotanuki.blockked.rx2idlerktx.Rx2IdlerKtx 11 | import io.dotanuki.common.toDate 12 | import io.reactivex.plugins.RxJavaPlugins 13 | import org.junit.Rule 14 | import org.junit.Test 15 | import org.junit.runner.RunWith 16 | import org.kodein.di.generic.bind 17 | import org.kodein.di.generic.provider 18 | 19 | 20 | @RunWith(AndroidJUnit4::class) 21 | class DashboardAcceptanceTests { 22 | 23 | init { 24 | RxJavaPlugins.setInitIoSchedulerHandler( 25 | Rx2IdlerKtx.create("RxJava2-IOScheduler") 26 | ) 27 | } 28 | 29 | 30 | private val broker = mock() 31 | 32 | @get:Rule val overwriter = BindingsOverwriter { 33 | bind(overrides = true) with provider { 34 | broker 35 | } 36 | } 37 | 38 | @Test 39 | fun atDashboardLaunch_givenSuccessAndSeveralBitcoinValues_ThenDisplayAndGraphShown() { 40 | 41 | val infoForGraphAndDisplay = BitcoinStatistic( 42 | providedName = "Market Price (USD)", 43 | providedDescription = "Average USD market value across major bitcoin exchanges.", 44 | unitName = "USD", 45 | measures = listOf( 46 | TimeBasedMeasure( 47 | dateTime = "2018-10-21T22:00:00".toDate(), 48 | value = 6498.48f 49 | ), 50 | TimeBasedMeasure( 51 | dateTime = "2018-10-22T22:00:00".toDate(), 52 | value = 6481.42f 53 | ), 54 | TimeBasedMeasure( 55 | dateTime = "2018-10-23T22:00:00".toDate(), 56 | value = 6511.32f 57 | ) 58 | ) 59 | ) 60 | 61 | given(broker) { 62 | defineScenario { 63 | criteria = DataFechted(infoForGraphAndDisplay) 64 | } 65 | } 66 | 67 | val scenario = launchActivity().apply { 68 | moveToState(RESUMED) 69 | } 70 | 71 | assertThat { 72 | 73 | loadingIndicator { 74 | should be hidden 75 | } 76 | 77 | errorReport { 78 | should be hidden 79 | } 80 | 81 | dashboard { 82 | should have DisplayAndGraph(bitcoinValue = "$6,511.32") 83 | } 84 | } 85 | 86 | scenario.close() 87 | } 88 | 89 | @Test fun atDashboardLaunch_givenJustOneBitcoinValue_ThenOnlyDisplayIsShown() { 90 | 91 | val justOneValueAtChart = BitcoinStatistic( 92 | providedName = "Market Price (USD)", 93 | providedDescription = "Average USD market value across major bitcoin exchanges.", 94 | unitName = "USD", 95 | measures = listOf( 96 | TimeBasedMeasure( 97 | dateTime = "2018-10-21T22:00:00".toDate(), 98 | value = 6498.48f 99 | ) 100 | ) 101 | ) 102 | 103 | given(broker) { 104 | defineScenario { 105 | criteria = DataFechted(justOneValueAtChart) 106 | } 107 | } 108 | 109 | val scenario = launchActivity().apply { 110 | moveToState(RESUMED) 111 | } 112 | 113 | assertThat { 114 | 115 | loadingIndicator { 116 | should be hidden 117 | } 118 | 119 | errorReport { 120 | should be hidden 121 | } 122 | 123 | dashboard { 124 | should have OnlyDisplay(bitcoinValue = "$6,498.48") 125 | } 126 | } 127 | 128 | scenario.close() 129 | 130 | } 131 | 132 | @Test fun atDashboardLaunch_givenNetworkingError_thenErrorReported() { 133 | val networkingError = NetworkingIssue.OperationTimeout 134 | checkErrorState(networkingError) 135 | } 136 | 137 | @Test fun atDashboardLaunch_givenIntegrationError_thenErrorReported() { 138 | val integrationError = RemoteIntegrationIssue.RemoteSystem 139 | checkErrorState(integrationError) 140 | } 141 | 142 | private fun checkErrorState(error: Throwable) { 143 | 144 | given(broker) { 145 | defineScenario { 146 | criteria = IssueFound(error) 147 | } 148 | } 149 | 150 | val scenario = launchActivity().apply { 151 | moveToState(RESUMED) 152 | } 153 | 154 | assertThat { 155 | 156 | loadingIndicator { 157 | should be hidden 158 | } 159 | 160 | errorReport { 161 | should be displayedWith(error.toString()) 162 | } 163 | 164 | dashboard { 165 | should have noEntries 166 | } 167 | } 168 | 169 | scenario.close() 170 | 171 | } 172 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | --------------------------------------------------------------------------------