├── 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 |
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 | 
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 |
--------------------------------------------------------------------------------