diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ada4297 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# http://editorconfig.org +root = true + +# noinspection EditorConfigKeyCorrectness +[*.{kt, kts}] +indent_size = 4 +indent_style = space +continuation_indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 +# Disabling rules that were added in the latest versions of ktlint +disabled_rules = import-ordering, experimental:indent + +[gradlew.bat] +end_of_line = crlf + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..d6674ae --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,16 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# More details are here: https://help.github.com/articles/about-codeowners/ + +# The '*' pattern is global owners. +# Not adding in this PR, but I'd like to try adding a global owner set with the entire team. +# One interpretation of their docs is that global owners are added only if not removed +# by a more local rule. + +# Order is important. The last matching pattern has the most precedence. +# The folders are ordered as follows: + +# In each subsection folders are ordered first by depth, then alphabetically. +# This should make it easy to add new rules without breaking existing ones. +* @nuhkoca \ No newline at end of file diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties new file mode 100644 index 0000000..f7399d8 --- /dev/null +++ b/.github/ci-gradle.properties @@ -0,0 +1,6 @@ +org.gradle.daemon=false +org.gradle.parallel=true +org.gradle.jvmargs=-Xmx5120m +org.gradle.workers.max=2 +kotlin.incremental=false +kotlin.compiler.execution.strategy=in-process \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f761a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,139 @@ +name: Libbra + +on: + push: + branches: + - master + + pull_request: + branches: + - master + + types: [opened, synchronize] + +env: + CI: true + +jobs: + setup: + name: Setup + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + # Ensure .gradle/caches is empty before writing to it. + # This helps us stay within Github's cache size limits. + - name: Clean Cache + run: rm -rf ~/.gradle/caches + + - name: Install JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Check Dependency Updates + run: ./gradlew dependencyUpdates -Drevision=release + + # Restore the cache. + # Intentionally don't set 'restore-keys' so the cache never contains redundant dependencies. + - uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: gradle-${{ runner.os }}-${{ hashFiles('**/build.gradle.kts') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} }} + + check-style: + needs: setup + name: Check Style + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + # Restore the cache. + # Intentionally don't set 'restore-keys' so the cache never contains redundant dependencies. + - uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: gradle-${{ runner.os }}-${{ hashFiles('**/build.gradle.kts') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} }} + + - name: Run Lint + run: ./gradlew lintDebug + + - name: Run Ktlint + run: ./gradlew ktlintCheck + + - name: Run Detekt + run: ./gradlew detekt + + - name: Run Spotless + run: ./gradlew spotlessCheck + + - name: Upload Reports + uses: actions/upload-artifact@v1 + if: failure() + with: + name: reports + path: app/build/reports + + build: + needs: check-style + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + # Restore the cache. + # Intentionally don't set 'restore-keys' so the cache never contains redundant dependencies. + - uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: gradle-${{ runner.os }}-${{ hashFiles('**/build.gradle.kts') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} }} + + - name: Create Build Report + uses: eskatos/gradle-command-action@v1 + with: + arguments: assembleDebug --scan + failOnError: true + id: gradle + + - name: Upload Sample Artifacts + uses: actions/upload-artifact@v1 + if: always() + with: + name: apk + path: app/build/outputs/apk/ + + - name: Publish Build Report As Comment If Failure + uses: mshick/add-pr-comment@v1 + if: failure() + with: + message: Build failed ${{ steps.gradle.outputs.build-scan-url }} + repo-token: ${{ secrets.GITHUB_TOKEN }} + allow-repeats: false + + test: + needs: build + name: Unit Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + # Restore the cache. + # Intentionally don't set 'restore-keys' so the cache never contains redundant dependencies. + - uses: actions/cache@v1 + with: + path: ~/.gradle/caches + key: gradle-${{ runner.os }}-${{ hashFiles('**/build.gradle.kts') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} }} + + - name: Unit Tests + run: ./gradlew testDebugUnitTest :rules:test # lint tests requires this task + + - name: Instrumentation Test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + arch: x86_64 + script: ./gradlew connectedDebugAndroidTest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f1d137 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/.lint/lint.xml b/.lint/lint.xml new file mode 100644 index 0000000..df0cab5 --- /dev/null +++ b/.lint/lint.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..158c70c --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +## Libbra +![GitHub Actions status](https://github.com/nuhkoca/revolut-task-libbra/workflows/Libbra/badge.svg) +[![CodeStyle](https://img.shields.io/badge/code%20style-%E2%9D%A4-FF4081.svg)](https://ktlint.github.io/) +[![Kotlin Version](https://img.shields.io/badge/kotlin-1.3.61-blue.svg)](http://kotlinlang.org/) +[![Gradle](https://lv.binarybabel.org/catalog-api/gradle/latest.svg)](https://lv.binarybabel.org/catalog/gradle/latest) +[![API](https://img.shields.io/badge/API-21%2B-blue.svg?style=flat)](https://android-arsenal.com/api?level=21) +[![License](https://img.shields.io/badge/License-Apache%202.0-lightgrey.svg)](http://www.apache.org/licenses/LICENSE-2.0) + +

Libbra Preview

+ +Libbra is a sample app that allows to track currency exchanges. This app presents modern approach to [Android](https://www.android.com/) application development using [Kotlin](https://kotlinlang.org/) and latest tech-stack. + +This project is a hiring task by [Revolut](https://www.revolut.com/). The goal of the project is to demonstrate best practices, provide a set of guidelines, and present modern Android +application architecture that is modular, scalable, maintainable and testable. This application may look simple, but it +has all of these small details that will set the rock-solid foundation of the larger app suitable for bigger teams and +long application lifecycle management. + +## Table of Contents + +- [Development](https://github.com/nuhkoca/revolut-task-libbra#development) +- [Design](https://github.com/nuhkoca/revolut-task-libbra#design) +- [Architecture](https://github.com/nuhkoca/revolut-task-libbra#architecture) +- [Tech-stack](https://github.com/nuhkoca/revolut-task-libbra#tech-stack) +- [Author](https://github.com/nuhkoca/revolut-task-libbra#authors) +- [License](https://github.com/nuhkoca/revolut-task-libbra#license) + +## Development + +### Environment setup + +First off, you require the latest Android Studio 3.6.0 (or newer) to be able to build the app. + +Moreover, to sign your app for release, please refer to `keystore.properties` to find required fields. + +```properties +#Signing Config +signing.store.password= +signing.key.password= +signing.key.alias= +signing.store.file= +``` + +### Code style + +To maintain the style and quality of the code, are used the bellow static analysis tools. All of them use properly configuration and you find them in the project root directory `.{toolName}`. + +| Tools | Config file | Check command | Fix command | +|---------------------------------------------------------|----------------------------------------------------------------------------------:|---------------------------|---------------------------| +| [detekt](https://github.com/arturbosch/detekt) | [/.detekt](https://github.com/nuhkoca/revolut-task-libbra/tree/master/default-detekt-config.yml) | `./gradlew detekt` | - | +| [ktlint](https://github.com/JLLeitschuh/ktlint-gradle) | - | `./gradlew ktlintCheck` | `./gradlew ktlintFormat` | +| [spotless](https://github.com/diffplug/spotless) | [/.spotless](https://github.com/nuhkoca/revolut-task-libbra/tree/master/spotless) | `./gradlew spotlessCheck` | `./gradlew spotlessApply` | +| [lint](https://developer.android.com/studio/write/lint) | [/.lint](https://github.com/nuhkoca/revolut-task-libbra/tree/master/.lint) | `./gradlew lint` | - | + +All these tools are integrated in [pre-commit git hook](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks), in order +ensure that all static analysis and tests passes before you can commit your changes. To skip them for specific commit add this option at your git command: + +```shell +git commit --no-verify +``` + +The pre-commit git hooks have exactly the same checks as [Github Actions](https://github.com/actions) and are defined in this [script](https://github.com/nuhkoca/revolut-task-libbra/blob/master/scripts/git-hooks/pre-commit.sh). This step ensures that all commits comply with the established rules. However the continuous integration will ultimately be validated that the changes are correct. + +## Design + +App [support different screen sizes](https://developer.android.com/training/multiscreen/screensizes) and the content has been adapted to fit for mobile devices and tablets. To do that, it has been created a flexible layout using one or more of the following concepts: + +- [Use constraintLayout](https://developer.android.com/training/multiscreen/screensizes#ConstraintLayout) +- [Avoid hard-coded layout sizes](https://developer.android.com/training/multiscreen/screensizes#TaskUseWrapMatchPar) +- [Create alternative layouts](https://developer.android.com/training/multiscreen/screensizes#alternative-layouts) +- [Use the smallest width qualifier](https://developer.android.com/training/multiscreen/screensizes#TaskUseSWQuali) +- [Use the available width qualifier](https://developer.android.com/training/multiscreen/screensizes#available-width) +- [Add orientation qualifiers](https://developer.android.com/training/multiscreen/screensizes#TaskUseOriQuali) + +In terms of design has been followed recommendations [android material design](https://developer.android.com/guide/topics/ui/look-and-feel) comprehensive guide for visual, motion, and interaction design across platforms and devices. Granting the project in this way a great user experience (UX) and user interface (UI). For more info about UX best practices visit [link](https://developer.android.com/topic/google-play-instant/best-practices/apps). + +Moreover, has been implemented support for [dark theme](https://developer.android.com/guide/topics/ui/look-and-feel/darktheme) with the following benefits: +- Can reduce power usage by a significant amount (depending on the device’s screen technology). +- Improves visibility for users with low vision and those who are sensitive to bright light. +- Makes it easier for anyone to use a device in a low-light environment. + +| Page | Light Mode | Dark Mode | +|-------|---------------------------------------------------|------------------------------------------| +| Currency | | | + +## Architecture + +The architecture of the application is based, apply and strictly complies with each of the following 5 points: + +- A single-activity architecture, using the [Navigation component](https://developer.android.com/guide/navigation/navigation-getting-started) to manage fragment operations. +- [Android architecture components](https://developer.android.com/topic/libraries/architecture/), part of Android Jetpack for give to project a robust design, testable and maintainable. +- Pattern [Model-View-ViewModel](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) (MVVM) facilitating a [separation](https://en.wikipedia.org/wiki/Separation_of_concerns) of development of the graphical user interface. +- [S.O.L.I.D](https://en.wikipedia.org/wiki/SOLID) design principles intended to make software designs more understandable, flexible and maintainable. +- [Modular app architecture](https://proandroiddev.com/build-a-modular-android-app-architecture-25342d99de82) allows to be developed features in isolation, independently from other features. + +### Modules + +Modules are collection of source files and build settings that allow you to divide a project into discrete units of functionality. In this case apart from dividing by functionality/responsibility, existing the following dependence between them: + +The above graph shows the app modularisation: + +- `:app` depends on `:rules`. +- `:rules` depends on nothing. + +#### App module + +The `:app` module is an [com.android.application](https://developer.android.com/studio/build/), which is needed to create the app bundle. It is also responsible for initiating the [dependency graph](https://github.com/google/dagger) and another project global libraries, differentiating especially between different app environments. + +#### Rules modules + +The `:rules` module is an [com.android.library](https://developer.android.com/studio/projects/android-library), basically contains lint checks for the entire project. + +### Architecture components + +Ideally, ViewModels shouldn’t know anything about Android. This improves testability, leak safety and modularity. ViewModels have different scopes than activities or fragments. While a ViewModel is alive and running, an activity can be in any of its lifecycle states. Activities and fragments can be destroyed and created again while the ViewModel is unaware. + +Passing a reference of the View (activity or fragment) to the ViewModel is a serious risk. Lets assume the ViewModel requests data from the network and the data comes back some time later. At that moment, the View reference might be destroyed or might be an old activity that is no longer visible, generating a memory leak and, possibly, a crash. + + + +The communication between the different layers follow the above diagram using the reactive paradigm, observing changes on components without need of callbacks avoiding leaks and edge cases related with them. + +## Tech-stack + +This project takes advantage of many popular libraries, plugins and tools of the Android ecosystem. Most of the libraries are in the stable version, unless there is a good reason to use non-stable dependency. + +### Dependencies + +- [Jetpack](https://developer.android.com/jetpack): + - [Android KTX](https://developer.android.com/kotlin/ktx.html) - provide concise, idiomatic Kotlin to Jetpack and Android platform APIs. + - [AndroidX](https://developer.android.com/jetpack/androidx) - major improvement to the original Android [Support Library](https://developer.android.com/topic/libraries/support-library/index), which is no longer maintained. + - [Data Binding](https://developer.android.com/topic/libraries/data-binding/) - allows you to bind UI components in your layouts to data sources in your app using a declarative format rather than programmatically. + - [ViewBinding](https://developer.android.com/topic/libraries/view-binding) - allows you to more easily write code that interacts with views. + - [Lifecycle](https://developer.android.com/topic/libraries/architecture/lifecycle) - perform actions in response to a change in the lifecycle status of another component, such as activities and fragments. + - [LiveData](https://developer.android.com/topic/libraries/architecture/livedata) - lifecycle-aware, meaning it respects the lifecycle of other app components, such as activities, fragments, or services. + - [Navigation](https://developer.android.com/guide/navigation/) - helps you implement navigation, from simple button clicks to more complex patterns, such as app bars and the navigation drawer. + - [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) - designed to store and manage UI-related data in a lifecycle conscious way. The ViewModel class allows data to survive configuration changes such as screen rotations. +- [Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html) - managing background threads with simplified code and reducing needs for callbacks. +- [Dagger2](https://dagger.dev/) - dependency injector for replacement all FactoryFactory classes. +- [Retrofit](https://square.github.io/retrofit/) - type-safe HTTP client. +- [Coil](https://github.com/coil-kt/coil) - image loading library for Android backed by Kotlin Coroutines. +- [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization) - consists of a compiler plugin, that generates visitor code for serializable classes, runtime library with core serialization API and JSON format, and support libraries with ProtoBuf, CBOR and properties formats. +- [Timber](https://github.com/JakeWharton/timber) - a logger with a small, extensible API which provides utility on top of Android's normal Log class. +- [and more...](https://github.com/nuhkoca/revolut-task-libbra/blob/master/buildSrc/src/main/kotlin/dependencies/Dependencies.kt) + +### Test dependencies + +- [Orchestrator](https://developer.android.com/training/testing/junit-runner#using-android-test-orchestrator) - allows you to run each of your app's tests within its own invocation of Instrumentation. +- [Espresso](https://developer.android.com/training/testing/espresso) - to write concise, beautiful, and reliable Android UI tests +- [JUnit](https://github.com/junit-team/junit4) - a simple framework to write repeatable tests. It is an instance of the xUnit architecture for unit testing frameworks. +- [JUnit5](https://github.com/mannodermaus/android-junit5) - a Gradle plugin that allows for the execution of JUnit 5 tests in Android environments using Android Gradle Plugin 3.5.0 or later. +- [Mockk](https://github.com/mockk/mockk) - provides DSL to mock behavior. Built from zero to fit Kotlin language. +- [AndroidX](https://github.com/android/android-test) - the androidx test library provides an extensive framework for testing Android apps. +- [and more...](https://github.com/nuhkoca/revolut-task-libbra/blob/master/buildSrc/src/main/kotlin/dependencies/Dependencies.kt) + +### Plugins + +- [Ktlint](https://github.com/JLLeitschuh/ktlint-gradle) - a pluging that creates convenient tasks in your Gradle project that run ktlint checks or do code auto format. +- [Detekt](https://github.com/arturbosch/detekt) - a static code analysis tool for the Kotlin programming language. +- [Spotless](https://github.com/diffplug/spotless) - a code formatter can do more than just find formatting errors. +- [Versions](https://github.com/ben-manes/gradle-versions-plugin) - make easy to determine which dependencies have updates. +- [JUnit5](https://github.com/mannodermaus/android-junit5) - a Gradle plugin that allows for the execution of JUnit5 tests in Android environments using Android Gradle Plugin 3.5.0 or later. +- [and more...](https://github.com/nuhkoca/revolut-task-libbra/blob/master/buildSrc/src/main/kotlin/plugins/BuildPlugins.kt) + +## Authors + + + + + +**Nuh Koca** + +[![Linkedin](https://img.shields.io/badge/-linkedin-grey?logo=linkedin)](https://www.linkedin.com/in/nuhkoca/) +[![Twitter](https://img.shields.io/badge/-twitter-grey?logo=twitter)](https://twitter.com/_nuhkoca) +[![Medium](https://img.shields.io/badge/-medium-grey?logo=medium)](https://medium.com/@nuhkocaa) +[![Web](https://img.shields.io/badge/-web-grey?logo=appveyor)](http://nuhkoca.com/) + +## License + +* The preview images were created using 'Previewed' at previewed.app +* The currency and application icons were created at iconscout.com + +```license +Copyright 2020 Nuh Koca + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..33cc29e --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import common.addJUnit5TestDependencies +import common.addOkHttpBom +import common.addTestDependencies +import dependencies.Dependencies +import extensions.applyDefault +import extensions.configureAndroidTests +import extensions.createDebug +import extensions.createKotlinAndroidTest +import extensions.createKotlinMain +import extensions.createKotlinTest +import extensions.createRelease +import extensions.createReleaseConfig +import extensions.getSemanticAppVersionName +import extensions.setDefaults +import utils.javaVersion + +plugins { + id(Plugins.androidApplication) + kotlin(Plugins.kotlinAndroid) + kotlin(Plugins.kotlinAndroidExtension) + kotlin(Plugins.kotlinKapt) + id(Plugins.kotlinSerialization) + id(Plugins.junit5) +} + +val baseUrl: String by project + +android { + compileSdkVersion(extra["compileSdkVersion"] as Int) + + defaultConfig { + applicationId = "io.github.nuhkoca.libbra" + minSdkVersion(extra["minSdkVersion"] as Int) + targetSdkVersion(extra["targetSdkVersion"] as Int) + versionCode = 1 + versionName = getSemanticAppVersionName() + + vectorDrawables.useSupportLibrary = true + testApplicationId = "io.github.nuhkoca.libbra.test" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArgument(Config.JUNIT5_KEY, Config.JUNIT5_VALUE) + testInstrumentationRunnerArgument(Config.ORCHESTRATOR_KEY, Config.ORCHESTRATOR_VALUE) + + configureAndroidTests() + + signingConfig = signingConfigs.getByName("debug") + + // All supported languages should be added here. It tells all libraries that we only want to + // compile these languages into our project -> Reduces .APK size + resConfigs("en") + } + + // 4) JUnit 5 will bundle in files with identical paths; exclude them + packagingOptions { + exclude("META-INF/LICENSE*") + } + + signingConfigs { + createReleaseConfig(this) + } + + buildTypes { + createRelease(this) + createDebug(this) + + forEach { type -> + if (type.name == "release") { + type.signingConfig = signingConfigs.getByName(type.name) + } + + type.buildConfigField("String", "BASE_URL", baseUrl) + } + } + + sourceSets { + createKotlinMain(this) + createKotlinTest(this) + createKotlinAndroidTest(this) + } + + compileOptions { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + } + + kotlinOptions { + jvmTarget = javaVersion.toString() + } + + androidExtensions { + isExperimental = true + } + + dataBinding { + isEnabled = true + } + + viewBinding { + isEnabled = true + } + + lintOptions.setDefaults() + + testOptions.applyDefault() +} + +dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + lintChecks(project(Modules.lintRules)) + + implementation(Dependencies.Core.kotlin) + implementation(Dependencies.Core.coroutines) + + implementation(Dependencies.UI.material) + implementation(Dependencies.UI.core_ktx) + implementation(Dependencies.UI.appcompat) + implementation(Dependencies.UI.fragment_ktx) + implementation(Dependencies.UI.activity_ktx) + implementation(Dependencies.UI.recylerview) + implementation(Dependencies.UI.constraint_layout) + + implementation(Dependencies.Navigation.nav_fragment_ktx) + implementation(Dependencies.Navigation.nav_ui_ktx) + + implementation(Dependencies.Lifecycle.lifecycle_extensions) + implementation(Dependencies.Lifecycle.viewmodel_ktx) + implementation(Dependencies.Lifecycle.livedata_ktx) + implementation(Dependencies.Lifecycle.runtime_ktx) + implementation(Dependencies.Lifecycle.common_java) + + implementation(Dependencies.Dagger.dagger) + kapt(Dependencies.Dagger.compiler) + + implementation(Dependencies.Network.retrofit) + implementation(Dependencies.Network.retrofit_serialization_adapter) + + addOkHttpBom() + + implementation(Dependencies.Other.lottie) + implementation(Dependencies.Other.timber) + implementation(Dependencies.Other.coil) + + addTestDependencies() + addJUnit5TestDependencies() +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..64b847c --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,89 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and +# EnclosingMethod is required to use InnerClasses. +-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod, Exception + +# Retrofit does reflection on method and parameter annotations. +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations + +# Retain service method parameters when optimizing. +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} + +# Ignore annotation used for build tooling. +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement + +# Ignore JSR 305 annotations for embedding nullability information. +-dontwarn javax.annotation.** + +# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. +-dontwarn kotlin.Unit + +# Top-level functions that can only be used by Kotlin. +-dontwarn retrofit2.KotlinExtensions + +# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy +# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> + +# A resource is loaded with a relative path so the package of this class must be preserved. +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase +-dontwarn org.conscrypt.ConscryptHostnameVerifier + +# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java. +-dontwarn org.codehaus.mojo.animal_sniffer.* + +# OkHttp platform used only on JVM and when Conscrypt dependency is available. +-dontwarn okhttp3.internal.platform.ConscryptPlatform + +# Dagger 2 +-dontwarn com.google.errorprone.annotations.** + +# Okio +-keep class sun.misc.Unsafe { *; } +-dontwarn java.nio.file.* +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn okio.** + +-dontnote kotlinx.serialization.SerializationKt +-keep,includedescriptorclasses class io.github.nuhkoca.libbra.**$$serializer { *; } +-keepclassmembers class io.github.nuhkoca.libbra.** { + *** Companion; +} +-keepclasseswithmembers class io.github.nuhkoca.libbra.** { + kotlinx.serialization.KSerializer serializer(...); +} + +-dontwarn java.awt.** +-dontwarn javax.validation.** +-dontwarn org.springframework.** + +# Navigation Architecture Components +-keep class * extends androidx.fragment.app.Fragment{} + +# Lottie +-dontwarn com.airbnb.lottie.** +-keep class com.airbnb.lottie.** {*;} diff --git a/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/ui/MainActivityRobot.kt b/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/ui/MainActivityRobot.kt new file mode 100644 index 0000000..6147216 --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/ui/MainActivityRobot.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import io.github.nuhkoca.libbra.R + +/** + * Robot pattern to verify [MainActivity]'s assertions in [MainActivityTest] + */ +fun launchMain(func: MainActivityRobot.() -> Unit) = MainActivityRobot().apply { func() } + +class MainActivityRobot { + fun verifyToolbar() { + onView(withId(R.id.toolbar)).check(matches(isDisplayed())) + } +} diff --git a/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/ui/MainActivityTest.kt b/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/ui/MainActivityTest.kt new file mode 100644 index 0000000..70d6d93 --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/ui/MainActivityTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui + +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.IdlingResource +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.nuhkoca.libbra.util.DataBindingIdlingResource +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A test class for [MainActivity] + */ +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule + var activityScenarioRule = ActivityScenarioRule(MainActivity::class.java) + + private lateinit var dataBindingIdlingResource: IdlingResource + + @Before + fun registerIdlingResources() { + dataBindingIdlingResource = DataBindingIdlingResource(activityScenarioRule) + IdlingRegistry.getInstance().register(dataBindingIdlingResource) + } + + @After + fun unregisterIdlingResources() { + IdlingRegistry.getInstance().unregister(dataBindingIdlingResource) + } + + @Test + fun activityLaunchesAndToolbarIsDisplayed() { + launchMain { + verifyToolbar() + } + } +} diff --git a/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/util/DataBindingIdlingResource.kt b/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/util/DataBindingIdlingResource.kt new file mode 100644 index 0000000..9b21d8c --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/nuhkoca/libbra/util/DataBindingIdlingResource.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util + +import android.view.View +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.fragment.app.FragmentActivity +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.IdlingResource +import androidx.test.ext.junit.rules.ActivityScenarioRule +import java.util.* + +/** + * An espresso idling resource implementation that reports idle status for all data binding + * layouts. Data Binding uses a mechanism to post messages which Espresso doesn't track yet. + * + * Since this application only uses fragments, the resource only checks the fragments and their + * children instead of the whole view tree. + * + * Tracking bug: https://github.com/android/android-test/issues/317 + */ +class DataBindingIdlingResource( + activityScenarioRule: ActivityScenarioRule +) : IdlingResource { + // list of registered callbacks + private val idlingCallbacks = mutableListOf() + + // give it a unique id to workaround an espresso bug where you cannot register/unregister + // an idling resource w/ the same name. + private val id = UUID.randomUUID().toString() + + // holds whether isIdle is called and the result was false. We track this to avoid calling + // onTransitionToIdle callbacks if Espresso never thought we were idle in the first place. + private var wasNotIdle = false + + lateinit var activity: FragmentActivity + + override fun getName() = "DataBinding $id" + + init { + monitorActivity(activityScenarioRule.scenario) + } + + override fun isIdleNow(): Boolean { + val idle = !getBindings().any { it.hasPendingBindings() } + @Suppress("LiftReturnOrAssignment") + if (idle) { + if (wasNotIdle) { + // notify observers to avoid espresso race detector + idlingCallbacks.forEach { it.onTransitionToIdle() } + } + wasNotIdle = false + } else { + wasNotIdle = true + // check next frame + activity.findViewById(android.R.id.content).postDelayed({ + isIdleNow + }, 16) + } + return idle + } + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) { + idlingCallbacks.add(callback) + } + + /** + * Sets the activity from an [ActivityScenario] to be used from [DataBindingIdlingResource]. + */ + private fun monitorActivity( + activityScenario: ActivityScenario + ) { + activityScenario.onActivity { + this.activity = it + } + } + + /** + * Find all binding classes in all currently available fragments. + */ + private fun getBindings(): List { + val fragments = (activity as? FragmentActivity) + ?.supportFragmentManager + ?.fragments + + val bindings = + fragments?.mapNotNull { + it.view?.getBinding() + } ?: emptyList() + val childrenBindings = fragments?.flatMap { it.childFragmentManager.fragments } + ?.mapNotNull { it.view?.getBinding() } ?: emptyList() + + return bindings + childrenBindings + } +} + +private fun View.getBinding(): ViewDataBinding? = DataBindingUtil.getBinding(this) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..86686f8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..e3cca40 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/LibbraApplication.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/LibbraApplication.kt new file mode 100644 index 0000000..8c566fc --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/LibbraApplication.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra + +import android.app.Application +import androidx.databinding.DataBindingUtil +import io.github.nuhkoca.libbra.BuildConfig.DEBUG +import io.github.nuhkoca.libbra.di.AppComponent +import io.github.nuhkoca.libbra.di.DaggerAppComponent +import timber.log.Timber +import timber.log.Timber.DebugTree + +/** + * An application that initializes Dagger and lazily provides [AppComponent]. + * + * Also, sets up Timber in the DEBUG BuildConfig. + */ +class LibbraApplication : Application() { + + // Instance of the AppComponent that will be used by all the Activities in the project + val appComponent: AppComponent by lazy { + initializeComponent() + } + + private fun initializeComponent(): AppComponent { + // Creates an instance of AppComponent using its Factory constructor + // We pass the applicationContext that will be used as Context in the graph + return DaggerAppComponent.factory().create(applicationContext).also { appComponent -> + val bindingComponent = appComponent.dataBindingComponent().create() + DataBindingUtil.setDefaultComponent(bindingComponent) + } + } + + override fun onCreate() { + super.onCreate() + if (DEBUG) Timber.plant(DebugTree()) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/adapters/ImageBindingAdapter.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/adapters/ImageBindingAdapter.kt new file mode 100644 index 0000000..ae4db18 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/adapters/ImageBindingAdapter.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.binding.adapters + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.databinding.BindingAdapter +import coil.ImageLoader +import coil.api.load +import coil.target.Target +import io.github.nuhkoca.libbra.binding.di.BindingScope +import io.github.nuhkoca.libbra.util.ext.e +import io.github.nuhkoca.libbra.util.ext.i +import javax.inject.Inject + +/** + * A [BindingAdapter] for ImageView processes. + * + * @property imageLoader The Coil loader + */ +@BindingScope +class ImageBindingAdapter @Inject constructor(private val imageLoader: ImageLoader) { + + /** + * Binds image to target ImageView + * + * @param resId The res id + */ + @BindingAdapter("android:src") + fun ImageView.bindImage(@DrawableRes resId: Int) { + load(resId, imageLoader) { + size(IMAGE_WIDTH_SIZE_DEFAULT, IMAGE_HEIGHT_SIZE_DEFAULT) + target(CustomTarget(this@bindImage)) + build() + } + } + + private companion object { + private const val IMAGE_WIDTH_SIZE_DEFAULT = 200 + private const val IMAGE_HEIGHT_SIZE_DEFAULT = 200 + } +} + +/** + * A custom [Target] that only logs events. + * + * @param view The ImageView to load the requested drawable + */ +private open class CustomTarget(private val view: ImageView) : Target { + override fun onStart(placeholder: Drawable?) { + super.onStart(placeholder) + i { "Image loading has started." } + } + + override fun onSuccess(result: Drawable) { + super.onSuccess(result) + view.background = result + i { "Image loading is successful." } + } + + override fun onError(error: Drawable?) { + super.onError(error) + e { "Image loading is failed." } + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/adapters/TextBindingAdapter.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/adapters/TextBindingAdapter.kt new file mode 100644 index 0000000..511368d --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/adapters/TextBindingAdapter.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.binding.adapters + +import androidx.databinding.BindingAdapter +import com.google.android.material.textfield.TextInputEditText +import io.github.nuhkoca.libbra.binding.di.BindingScope +import java.text.DecimalFormat +import java.util.* +import javax.inject.Inject + +/** + * A [BindingAdapter] for views whose can work with text. + */ +@BindingScope +class TextBindingAdapter @Inject constructor() { + + /** + * Binds amount to target TextView + * + * @param amount The amount + */ + @BindingAdapter("android:text") + fun TextInputEditText.bindAmount(amount: Float) { + val formattedAmount = DecimalFormat.getInstance(Locale.getDefault()).format(amount) + setText(formattedAmount) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/di/BindingComponent.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/di/BindingComponent.kt new file mode 100644 index 0000000..329a88d --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/di/BindingComponent.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.binding.di + +import androidx.databinding.DataBindingComponent +import dagger.Subcomponent +import io.github.nuhkoca.libbra.binding.adapters.ImageBindingAdapter +import io.github.nuhkoca.libbra.binding.adapters.TextBindingAdapter + +@BindingScope +@Subcomponent(modules = [BindingModule::class]) +interface BindingComponent : DataBindingComponent { + + @Subcomponent.Factory + interface Factory { + fun create(): BindingComponent + } + + override fun getImageBindingAdapter(): ImageBindingAdapter + + override fun getTextBindingAdapter(): TextBindingAdapter +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/di/BindingModule.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/di/BindingModule.kt new file mode 100644 index 0000000..3d66ba3 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/binding/di/BindingModule.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.binding.di + +import android.content.Context +import coil.ImageLoader +import coil.ImageLoaderBuilder +import dagger.Module +import dagger.Provides +import io.github.nuhkoca.libbra.binding.adapters.ImageBindingAdapter +import io.github.nuhkoca.libbra.binding.adapters.TextBindingAdapter +import javax.inject.Qualifier +import javax.inject.Scope + +@Module +internal object BindingModule { + + private const val DEFAULT_MEMORY_MULTIPLIER = 0.5 + + @Provides + @BindingScope + internal fun provideImageLoader(context: Context) = ImageLoaderBuilder(context).apply { + availableMemoryPercentage(DEFAULT_MEMORY_MULTIPLIER) + crossfade(true) + }.build() + + @Provides + @InternalApi + @BindingScope + internal fun provideImageBindingAdapter( + imageLoader: ImageLoader + ) = ImageBindingAdapter(imageLoader) + + @Provides + @InternalApi + @BindingScope + internal fun provideTextBindingAdapter() = TextBindingAdapter() +} + +@Scope +@MustBeDocumented +internal annotation class BindingScope + +@Qualifier +@Retention(AnnotationRetention.BINARY) +@MustBeDocumented +private annotation class InternalApi diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/Result.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/Result.kt new file mode 100644 index 0000000..555731e --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/Result.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data + +import io.github.nuhkoca.libbra.data.failure.Failure + +/** + * A generic class that holds a value with its data status. + * @param + */ +sealed class Result { + + /** + * Represents success data operation. + * + * @param data the data + * + * @return the data with [Result] wrapper. + */ + data class Success(val data: T) : Result() + + /** + * Represents error operation. + * + * @param failure the [Failure] + * + * @return [Nothing] with [Result] wrapper. + */ + data class Error(val failure: Failure) : Result() { + override fun toString() = "Failure $failure" + } +} + +/** + * `true` if [Result] is of type [Result.Success] & holds non-null [Result.Success.data]. + */ +internal val Result<*>.succeeded + get() = this is Result.Success && data != null + +/** + * Returns the result of [onSuccess] for encapsulated value if this instance represents success + * or the result of [onFailure] function for encapsulated exception if it is failure. + */ +fun Result.fold(onSuccess: (T) -> R, onFailure: (Failure) -> R): R { + return if (succeeded) { + onSuccess((this as Result.Success).data) + } else { + onFailure((this as Result.Error).failure) + } +} + +/** + * Returns the suspend result of [onSuccess] for encapsulated value if this instance represents + * success or the suspend result of [onFailure] function for encapsulated failure if it is failure. + */ +suspend fun Result.foldSuspend( + onSuccess: suspend (T) -> R, + onFailure: suspend (Failure) -> R +): R { + return if (succeeded) { + onSuccess((this as Result.Success).data) + } else { + onFailure((this as Result.Error).failure) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/datasource/CurrencyRemoteDataSource.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/datasource/CurrencyRemoteDataSource.kt new file mode 100644 index 0000000..254170e --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/datasource/CurrencyRemoteDataSource.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.datasource + +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.raw.CurrencyResponseRaw +import io.github.nuhkoca.libbra.data.service.CurrencyService +import io.github.nuhkoca.libbra.util.coroutines.AsyncManager +import io.github.nuhkoca.libbra.util.coroutines.DefaultAsyncManager +import io.github.nuhkoca.libbra.util.coroutines.DispatcherProvider +import io.github.nuhkoca.libbra.util.mapper.Mapper +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A [DataSource] implementation to fetch list of currencies remotely. + * + * @param currencyService The service to hit the endpoint + * @param mapper The domain mapper to map raw data to domain + * @param dispatcherProvider The [DispatcherProvider] to run calls under a specific context + */ +@Singleton +class CurrencyRemoteDataSource @Inject constructor( + private val currencyService: CurrencyService, + private val mapper: @JvmSuppressWildcards Mapper, + private val dispatcherProvider: DispatcherProvider +) : DataSource, AsyncManager by DefaultAsyncManager(dispatcherProvider) { + + /** + * Fetches list of currencies and returns in [Flow] builder + * + * @param base The base currency to fetch list + * + * @return [CurrencyResponse] within [Flow] builder + */ + @ExperimentalCoroutinesApi + override fun getCurrencyList(base: Rate): Flow> { + return handleAsyncWithTryCatch { + val response = currencyService.getCurrencyList(base) + mapper.map(response) + } + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/datasource/DataSource.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/datasource/DataSource.kt new file mode 100644 index 0000000..a0e3cf8 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/datasource/DataSource.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.datasource + +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import kotlinx.coroutines.flow.Flow + +/** + * A common interface for children data sources to fetch list of currencies. + */ +@FunctionalInterface +interface DataSource { + + /** + * Fetches list of currencies and returns in [Flow] builder + * + * @param base The base currency to fetch list + * + * @return [CurrencyResponse] within [Flow] builder + */ + fun getCurrencyList(base: Rate): Flow> +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/enums/Rate.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/enums/Rate.kt new file mode 100644 index 0000000..979abe0 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/enums/Rate.kt @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.enums + +import androidx.annotation.DrawableRes +import io.github.nuhkoca.libbra.R + +/** + * An enum that represents all remote currencies. + * + * @property resId The drawable id + */ +enum class Rate(val longName: String, @DrawableRes val resId: Int) { + + /** + * Unknown type for undefined currency, this is to avoid crash for possible future + * implementations + */ + UNKNOWN("Unknown", R.drawable.ic_unknown), + + /** + * Currency type for Australian Dollar + */ + AUD("Australian Dollar", R.drawable.ic_aud), + + /** + * Currency type for Bulgarian Lev + */ + BGN("Bulgarian Lev", R.drawable.ic_bgn), + + /** + * Currency type for Brazilian Real + */ + BRL("Brazilian Real", R.drawable.ic_brl), + + /** + * Currency type for Canadian Dollar + */ + CAD("Canadian Dollar", R.drawable.ic_cad), + + /** + * Currency type for Swiss Franc + */ + CHF("Swiss Franc", R.drawable.ic_chf), + + /** + * Currency type for Chinese Yuan + */ + CNY("Chinese Yuan", R.drawable.ic_cny), + + /** + * Currency type for Czech Koruna + */ + CZK("Czech Koruna", R.drawable.ic_czk), + + /** + * Currency type for Danish Krone + */ + DKK("Danish Krone", R.drawable.ic_dkk), + + /** + * Currency type for Euro + */ + EUR("Euro", R.drawable.ic_eur), + + /** + * Currency type for Pound + */ + GBP("Pound", R.drawable.ic_gbp), + + /** + * Currency type for Hong Kong Dollar + */ + HKD("Hong Kong Dollar", R.drawable.ic_hkd), + + /** + * Currency type for Croatian Kuna + */ + HRK("Croatian Kuna", R.drawable.ic_hrk), + + /** + * Currency type for Hungarian Forint + */ + HUF("Hungarian Forint", R.drawable.ic_huf), + + /** + * Currency type for Indonesian Rupiah + */ + IDR("Indonesian Rupiah", R.drawable.ic_idr), + + /** + * Currency type for Israeli New Shekel + */ + ILS("Israeli New Shekel", R.drawable.ic_ils), + + /** + * Currency type for Indian Rupee + */ + INR("Indian Rupee", R.drawable.ic_inr), + + /** + * Currency type for Icelandic Króna + */ + ISK("Icelandic Króna", R.drawable.ic_isk), + + /** + * Currency type for Japanese Yen + */ + JPY("Japanese Yen", R.drawable.ic_jpy), + + /** + * Currency type for South Korean Won + */ + KRW("South Korean Won", R.drawable.ic_krw), + + /** + * Currency type for Mexican Peso + */ + MXN("Mexican Peso", R.drawable.ic_mxn), + + /** + * Currency type for Malaysian Ringgit + */ + MYR("Malaysian Ringgit", R.drawable.ic_myr), + + /** + * Currency type for Norwegian Krone + */ + NOK("Norwegian Krone", R.drawable.ic_nok), + + /** + * Currency type for New Zealand Dollar + */ + NZD("New Zealand Dollar", R.drawable.ic_nzd), + + /** + * Currency type for Philippine Peso + */ + PHP("Philippine Peso", R.drawable.ic_php), + + /** + * Currency type for Poland Złoty + */ + PLN("Poland Złoty", R.drawable.ic_pln), + + /** + * Currency type for Romanian Leu + */ + RON("Romanian Leu", R.drawable.ic_ron), + + /** + * Currency type for Russian Ruble + */ + RUB("Russian Ruble", R.drawable.ic_rub), + + /** + * Currency type for Swedish Krona + */ + SEK("Swedish Krona", R.drawable.ic_sek), + + /** + * Currency type for Singapore Dollar + */ + SGD("Singapore Dollar", R.drawable.ic_sgd), + + /** + * Currency type for Thai Baht + */ + THB("Thai Baht", R.drawable.ic_thb), + + /** + * Currency type for United States Dollar + */ + USD("United States Dollar", R.drawable.ic_usd), + + /** + * Currency type for South African Rand + */ + ZAR("South African Rand", R.drawable.ic_zar) +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/failure/ErrorResponse.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/failure/ErrorResponse.kt new file mode 100644 index 0000000..24febd8 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/failure/ErrorResponse.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.failure + +import kotlinx.serialization.Serializable + +/** + * A data class which represents error response. + * + * @property message The error message + */ +@Serializable +internal data class ErrorResponse( + val message: String +) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/failure/Failure.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/failure/Failure.kt new file mode 100644 index 0000000..47de8f3 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/failure/Failure.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.failure + +import io.github.nuhkoca.libbra.data.failure.Failure.CancellationFailure +import io.github.nuhkoca.libbra.data.failure.Failure.SerializationFailure +import io.github.nuhkoca.libbra.data.failure.Failure.ServerFailure +import io.github.nuhkoca.libbra.data.failure.Failure.UnhandledFailure +import okhttp3.OkHttpClient +import java.io.IOException + +/** + * Failure class to hold any kind of errors from server. [ServerFailure] represents error response + * e.g. parameters are incorrect, no result. [SerializationFailure] represents exceptional cases + * e.g. parse exception, serialization exception. [CancellationFailure] represents cancellation + * exception from Coroutines. [UnhandledFailure] represents unknown errors. + * + * @param message the error message + * + * @throws IOException as [OkHttpClient] is only able to consume [IOException] + * @see [Corresponding exception issue](https://github.com/square/retrofit/issues/3110) + * + * @author Nuh Koca + * @since 2020-03-05 + */ +sealed class Failure(override val message: String) : IOException(message) { + + /** + * Represents the error response itself. + * + * @param message the error message + */ + data class ServerFailure(override val message: String) : Failure(message) + + /** + * Represents other type of errors. + * + * @param message the error message + */ + data class SerializationFailure(override val message: String) : Failure(message = message) + + /** + * Represents cancellation errors from coroutines. + * + * @param message the error message + */ + data class CancellationFailure(override val message: String) : Failure(message = message) + + /** + * Represents unhandled errors by error handling mechanism. + * + * @param message the error message + */ + data class UnhandledFailure(override val message: String) : Failure(message = message) +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/mapper/CurrencyDomainMapper.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/mapper/CurrencyDomainMapper.kt new file mode 100644 index 0000000..ea89c7a --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/mapper/CurrencyDomainMapper.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.mapper + +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.domain.Rate +import io.github.nuhkoca.libbra.data.model.raw.CurrencyResponseRaw +import io.github.nuhkoca.libbra.util.coroutines.DispatcherProvider +import io.github.nuhkoca.libbra.util.mapper.Mapper +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A [Mapper] implementation to map [CurrencyResponseRaw] to [CurrencyResponse] type. + * + * @param dispatcherProvider The [DispatcherProvider] to run calls under a specific context + */ +@Singleton +class CurrencyDomainMapper @Inject constructor( + private val dispatcherProvider: DispatcherProvider +) : Mapper { + + /** + * A suspend function that maps [CurrencyResponseRaw] to [CurrencyResponse] type. + * + * @param item The [CurrencyResponseRaw] + * + * @return [CurrencyResponse] + */ + override suspend fun map(item: CurrencyResponseRaw) = withContext(dispatcherProvider.default) { + val rates = mutableListOf() + + item.rates.forEach { currency -> + val rate = Rate(currency.key, currency.value) + rates.add(rate) + } + + CurrencyResponse(item.baseCurrency, rates) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/domain/CurrencyResponse.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/domain/CurrencyResponse.kt new file mode 100644 index 0000000..4a6369d --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/domain/CurrencyResponse.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.model.domain + +/** + * A data class that includes list of currencies for domain layer + * + * @property baseCurrency The base currency to calculate amounts + * @property rates The list of currencies + */ +data class CurrencyResponse( + val baseCurrency: String, + val rates: List +) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/domain/Rate.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/domain/Rate.kt new file mode 100644 index 0000000..d62e9d7 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/domain/Rate.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.model.domain + +import io.github.nuhkoca.libbra.data.enums.Rate + +/** + * A data class that includes each currency for domain layer + * + * @property rate The currency + * @property amount The currency amount + */ +data class Rate( + val rate: Rate, + val amount: Float +) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/raw/CurrencyResponseRaw.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/raw/CurrencyResponseRaw.kt new file mode 100644 index 0000000..097eb99 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/raw/CurrencyResponseRaw.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:UseSerializers(RateSerializer::class) + +package io.github.nuhkoca.libbra.data.model.raw + +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.serializers.RateSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers + +/** + * A data class that includes list of currencies + * + * @property baseCurrency The base currency to calculate amounts + * @property rates The list of currencies + */ +@Serializable +data class CurrencyResponseRaw( + val baseCurrency: String, + val rates: Map +) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/view/CurrencyResponseViewItem.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/view/CurrencyResponseViewItem.kt new file mode 100644 index 0000000..bf88b6a --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/view/CurrencyResponseViewItem.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.model.view + +/** + * A data class that includes list of currencies for view layer + * + * @property baseCurrency The base currency to calculate amounts + * @property rates The list of currencies + */ +data class CurrencyResponseViewItem( + val baseCurrency: String, + val rates: List +) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/view/RateViewItem.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/view/RateViewItem.kt new file mode 100644 index 0000000..0807cc5 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/model/view/RateViewItem.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.model.view + +import androidx.annotation.DrawableRes + +/** + * A data class that includes each currency for view layer + * + * @property id The id for each item + * @property abbreviation The shortened currency name + * @property longName The long style of [abbreviation] + * @property amount The currency amount + * @property icon The currency icon that represents its country's flag + */ +data class RateViewItem( + val id: Int, + val abbreviation: String, + val longName: String, + val amount: Float, + @DrawableRes + val icon: Int +) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/serializers/RateSerializer.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/serializers/RateSerializer.kt new file mode 100644 index 0000000..59aaa13 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/serializers/RateSerializer.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.serializers + +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.util.ext.w +import kotlinx.serialization.Decoder +import kotlinx.serialization.Encoder +import kotlinx.serialization.KSerializer +import kotlinx.serialization.PrimitiveDescriptor +import kotlinx.serialization.PrimitiveKind +import kotlinx.serialization.SerialDescriptor +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.decode +import kotlinx.serialization.encode + +/** + * A custom [KSerializer] implementation to serialize and deserialize [Rate] elements. + */ +@Serializer(forClass = Rate::class) +object RateSerializer : KSerializer { + + private const val SERIAL_NAME = "rates" + + private val serializer = String.serializer() + + override val descriptor: SerialDescriptor = PrimitiveDescriptor( + SERIAL_NAME, + PrimitiveKind.STRING + ) + + /** + * Decodes a [Rate] from the given string or returns [Rate.UNKNOWN] if there is + * no matching. + * + * @return [Rate] or [Rate.UNKNOWN] if there is no matching + * + * @throws IllegalStateException if there is no match found + * @throws SerializationException if serializer is unable to serialize + */ + @Throws(IllegalStateException::class, SerializationException::class) + @Suppress("TooGenericExceptionCaught") + override fun deserialize(decoder: Decoder): Rate { + var type: String? = null + return try { + type = decoder.decode(serializer) + Rate.valueOf(type) + } catch (e: RuntimeException) { + w { "Falling back to UNKNOWN type as there is no match found for $type." } + Rate.UNKNOWN + } + } + + override fun serialize(encoder: Encoder, value: Rate) { + encoder.encode(serializer, value.name) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/service/CurrencyService.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/service/CurrencyService.kt new file mode 100644 index 0000000..4781d09 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/service/CurrencyService.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.service + +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.raw.CurrencyResponseRaw +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * The service interface to fetch list of currencies + */ +interface CurrencyService { + + /** + * Fetches list of currencies + * + * @param base The base currency to calculate amounts + * + * @return [CurrencyResponseRaw] + */ + @GET("$ENDPOINT_PREFIX/latest") + suspend fun getCurrencyList(@Query("base") base: Rate): CurrencyResponseRaw + + private companion object { + private const val ENDPOINT_PREFIX = "api/android" + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/data/verifier/RevolutHostnameVerifier.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/verifier/RevolutHostnameVerifier.kt new file mode 100644 index 0000000..044bf72 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/data/verifier/RevolutHostnameVerifier.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.verifier + +import io.github.nuhkoca.libbra.BuildConfig.BASE_URL +import io.github.nuhkoca.libbra.util.ext.d +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLSession + +/** + * A [HostnameVerifier] implementation for internal network operation. Other network traffics will + * be ignored. + */ +object RevolutHostnameVerifier : HostnameVerifier { + + private const val HOSTNAME_DELIMITER = "/" + + /** + * Verifies if network traffic is only over [BASE_URL]. + * + * @param hostname the host name + * @param session SSLSession used on the connection to host + * + * @return true if the host name is acceptable + */ + override fun verify(hostname: String?, session: SSLSession?): Boolean { + val expectedHostname = BASE_URL + .substringBeforeLast(HOSTNAME_DELIMITER) + .substringAfterLast(HOSTNAME_DELIMITER) + + d { "Hostname is $expectedHostname}" } + + return hostname.toString().contains(expectedHostname) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/di/AppComponent.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/di/AppComponent.kt new file mode 100644 index 0000000..b46f141 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/di/AppComponent.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.di + +import android.content.Context +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import io.github.nuhkoca.libbra.binding.di.BindingComponent +import io.github.nuhkoca.libbra.di.factory.FragmentBindingsModule +import io.github.nuhkoca.libbra.di.factory.ViewModelBuilderModule +import io.github.nuhkoca.libbra.ui.di.MainComponent +import javax.inject.Singleton + +/** + * The main component to build Dagger graph. + */ +@Singleton +@Component( + modules = [ + AppModule::class, + SubcomponentsModule::class, + FragmentBindingsModule::class, + ViewModelBuilderModule::class + ] +) +interface AppComponent { + + @Component.Factory + interface Factory { + fun create(@BindsInstance applicationContext: Context): AppComponent + } + + fun dataBindingComponent(): BindingComponent.Factory + + fun mainComponent(): MainComponent.Factory +} + +@Module( + subcomponents = [ + BindingComponent::class, + MainComponent::class + ] +) +object SubcomponentsModule diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/di/AppModule.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/di/AppModule.kt new file mode 100644 index 0000000..7551cac --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/di/AppModule.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.di + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.github.nuhkoca.libbra.BuildConfig +import io.github.nuhkoca.libbra.data.datasource.CurrencyRemoteDataSource +import io.github.nuhkoca.libbra.data.datasource.DataSource +import io.github.nuhkoca.libbra.data.mapper.CurrencyDomainMapper +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.raw.CurrencyResponseRaw +import io.github.nuhkoca.libbra.data.model.view.CurrencyResponseViewItem +import io.github.nuhkoca.libbra.data.service.CurrencyService +import io.github.nuhkoca.libbra.data.verifier.RevolutHostnameVerifier +import io.github.nuhkoca.libbra.domain.mapper.CurrencyViewItemMapper +import io.github.nuhkoca.libbra.domain.repository.CurrencyRepository +import io.github.nuhkoca.libbra.domain.repository.Repository +import io.github.nuhkoca.libbra.domain.usecase.CurrencyParams +import io.github.nuhkoca.libbra.domain.usecase.CurrencyUseCase +import io.github.nuhkoca.libbra.domain.usecase.UseCase +import io.github.nuhkoca.libbra.util.coroutines.DefaultDispatcherProvider +import io.github.nuhkoca.libbra.util.coroutines.DispatcherProvider +import io.github.nuhkoca.libbra.util.ext.errorInterceptor +import io.github.nuhkoca.libbra.util.mapper.Mapper +import kotlinx.serialization.UnstableDefault +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.create +import java.util.concurrent.TimeUnit +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +abstract class AppModule { + + @Binds + @Singleton + internal abstract fun bindDispatcherProvider( + defaultDispatcherProvider: DefaultDispatcherProvider + ): DispatcherProvider + + @Binds + @Remote + @Singleton + internal abstract fun bindCurrencyDataSource( + currencyRemoteDataSource: CurrencyRemoteDataSource + ): DataSource + + @Binds + @Singleton + internal abstract fun bindCurrencyDomainMapper( + currencyDomainMapper: CurrencyDomainMapper + ): Mapper + + @Binds + @Singleton + internal abstract fun bindCurrencyViewItemMapper( + currencyViewItemMapper: CurrencyViewItemMapper + ): Mapper + + @Binds + @Singleton + internal abstract fun bindCurrencyRepository( + currencyRepository: CurrencyRepository + ): Repository + + @Binds + @Singleton + internal abstract fun bindCurrencyUseCase( + currencyUseCase: CurrencyUseCase + ): UseCase.FlowUseCase + + @Module + internal companion object { + + private const val MEDIA_TYPE_DEFAULT = "application/json" + private const val TIMEOUT_IN_MS = 10000L + + @Provides + @Singleton + internal fun provideCurrencyService(retrofit: Retrofit): CurrencyService = retrofit.create() + + @Provides + @Singleton + @UnstableDefault + internal fun provideRetrofit(@InternalApi httpClient: OkHttpClient): Retrofit { + return Retrofit.Builder().apply { + baseUrl(BuildConfig.BASE_URL) + addConverterFactory(Json.asConverterFactory(MEDIA_TYPE_DEFAULT.toMediaType())) + client(httpClient) + }.build() + } + + @Provides + @Singleton + @InternalApi + @UnstableDefault + internal fun provideOkHttpClient( + @InternalApi loggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient { + return OkHttpClient.Builder().apply { + hostnameVerifier(RevolutHostnameVerifier) + connectTimeout(TIMEOUT_IN_MS, TimeUnit.MILLISECONDS) + readTimeout(TIMEOUT_IN_MS, TimeUnit.MILLISECONDS) + writeTimeout(TIMEOUT_IN_MS, TimeUnit.MILLISECONDS) + addInterceptor(loggingInterceptor) + addInterceptor(errorInterceptor()) + }.build() + } + + @Provides + @Singleton + @InternalApi + internal fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } + } + } +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +@MustBeDocumented +private annotation class InternalApi + +@Qualifier +@MustBeDocumented +annotation class Remote diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/di/factory/LibbraFragmentFactory.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/di/factory/LibbraFragmentFactory.kt new file mode 100644 index 0000000..1d311d8 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/di/factory/LibbraFragmentFactory.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.di.factory + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import dagger.Binds +import dagger.MapKey +import dagger.Module +import javax.inject.Inject +import javax.inject.Provider +import kotlin.annotation.AnnotationRetention.RUNTIME +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER +import kotlin.annotation.AnnotationTarget.PROPERTY_SETTER +import kotlin.reflect.KClass + +/** + * FragmentFactory which uses Dagger to create the instances. + */ +@Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown") +class LibbraFragmentFactory @Inject constructor( + private val creators: Map, @JvmSuppressWildcards Provider> +) : FragmentFactory() { + + override fun instantiate(classLoader: ClassLoader, className: String): Fragment { + val fragmentClass = loadFragmentClass(classLoader, className) + + val creator = creators[fragmentClass] ?: return super.instantiate(classLoader, className) + + return try { + creator.get() + } catch (e: RuntimeException) { + throw RuntimeException(e) + } + } +} + +@Module +internal abstract class FragmentBindingsModule { + + @Binds + internal abstract fun bindFragmentFactory( + fragmentFactory: LibbraFragmentFactory + ): FragmentFactory +} + +@MapKey +@Retention(RUNTIME) +@Target(FUNCTION, PROPERTY_GETTER, PROPERTY_SETTER) +internal annotation class FragmentKey(val value: KClass) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/di/factory/ViewModelFactory.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/di/factory/ViewModelFactory.kt new file mode 100644 index 0000000..43d9c14 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/di/factory/ViewModelFactory.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.di.factory + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Binds +import dagger.MapKey +import dagger.Module +import javax.inject.Inject +import javax.inject.Provider +import kotlin.annotation.AnnotationRetention.RUNTIME +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER +import kotlin.annotation.AnnotationTarget.PROPERTY_SETTER +import kotlin.reflect.KClass + +/** + * ViewModelFactory which uses Dagger to create the instances. + */ +@Suppress("TooGenericExceptionCaught", "TooGenericExceptionThrown") +class LibbraViewModelFactory @Inject constructor( + private val creators: @JvmSuppressWildcards Map, Provider> +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + var creator: Provider? = creators[modelClass] + if (creator == null) { + for ((key, value) in creators) { + if (modelClass.isAssignableFrom(key)) { + creator = value + break + } + } + } + if (creator == null) { + throw IllegalArgumentException("Unknown model class: $modelClass") + } + try { + @Suppress("UNCHECKED_CAST") + return creator.get() as T + } catch (e: Exception) { + throw RuntimeException(e) + } + } +} + +@Module +internal abstract class ViewModelBuilderModule { + + @Binds + internal abstract fun bindViewModelFactory( + factory: LibbraViewModelFactory + ): ViewModelProvider.Factory +} + +@MapKey +@Retention(RUNTIME) +@Target(FUNCTION, PROPERTY_GETTER, PROPERTY_SETTER) +internal annotation class ViewModelKey(val value: KClass) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/mapper/CurrencyViewItemMapper.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/mapper/CurrencyViewItemMapper.kt new file mode 100644 index 0000000..1c3d03b --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/mapper/CurrencyViewItemMapper.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.domain.mapper + +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.view.CurrencyResponseViewItem +import io.github.nuhkoca.libbra.data.model.view.RateViewItem +import io.github.nuhkoca.libbra.util.coroutines.DispatcherProvider +import io.github.nuhkoca.libbra.util.mapper.Mapper +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A [Mapper] implementation to map [CurrencyResponse] to [CurrencyResponseViewItem] type. + * + * @param dispatcherProvider The [DispatcherProvider] to run calls under a specific context + */ +@Singleton +class CurrencyViewItemMapper @Inject constructor( + private val dispatcherProvider: DispatcherProvider +) : Mapper { + + /** + * A suspend function that maps [CurrencyResponse] to [CurrencyResponseViewItem] type. + * + * @param item The [CurrencyResponse] + * + * @return [CurrencyResponseViewItem] + */ + override suspend fun map(item: CurrencyResponse) = withContext(dispatcherProvider.default) { + val rates = mutableListOf() + + rates with item.baseCurrency + + item.rates.forEachIndexed { index, currency -> + val rateViewItem = RateViewItem( + index + 1, + currency.rate.name, + currency.rate.longName, + currency.amount, + currency.rate.resId + ) + rates.add(index + 1, rateViewItem) + } + + CurrencyResponseViewItem(item.baseCurrency, rates) + } +} + +/** + * An infix fun that adds responder to currency list to show it at top. + * + * @param baseCurrency represents responder + */ +private infix fun MutableList.with(baseCurrency: String) { + // Add base currency to currency list to show it at top. + val rate = Rate.valueOf(baseCurrency) + val responder = RateViewItem( + 0, // First id should always be 0 + rate.name, + rate.longName, + 1f, // Because base currency amount is always 1 + rate.resId + ) + add(0, responder) +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/repository/CurrencyRepository.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/repository/CurrencyRepository.kt new file mode 100644 index 0000000..09d4c3f --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/repository/CurrencyRepository.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.domain.repository + +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.datasource.DataSource +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.di.Remote +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A [Repository] implementation to interact with [DataSource] in order to fetch list of + * currencies. + * + * @param remoteDataSource The data source + */ +@Singleton +class CurrencyRepository @Inject constructor( + @Remote private val remoteDataSource: DataSource +) : Repository { + + /** + * Fetches list of currencies and returns in [Flow] builder + * + * @param base The base currency to fetch list + * + * @return [CurrencyResponse] within [Flow] builder + */ + override fun getCurrencyList(base: Rate): Flow> { + return remoteDataSource.getCurrencyList(base) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/repository/Repository.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/repository/Repository.kt new file mode 100644 index 0000000..cc5f1c6 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/repository/Repository.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.domain.repository + +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.datasource.DataSource +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import kotlinx.coroutines.flow.Flow + +/** + * A helper interface for repository layer to interact with [DataSource] + */ +@FunctionalInterface +interface Repository { + + /** + * Fetches list of currencies and returns in [Flow] builder + * + * @param base The base currency to fetch list + * + * @return [CurrencyResponse] within [Flow] builder + */ + fun getCurrencyList(base: Rate): Flow> +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/usecase/CurrencyUseCase.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/usecase/CurrencyUseCase.kt new file mode 100644 index 0000000..f6a4acc --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/usecase/CurrencyUseCase.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.domain.usecase + +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.domain.repository.Repository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A [UseCase.FlowUseCase] implementation to interact with [Repository] in order to fetch list of + * currencies. + * + * @param repository The repository + */ +@Singleton +class CurrencyUseCase @Inject constructor( + private val repository: Repository +) : UseCase.FlowUseCase { + + /** + * Executes the call with the given parameters. + * + * @param params The [CurrencyParams] to fetch list + * + * @return [CurrencyResponse] within [Flow] builder + */ + override fun execute(params: CurrencyParams): Flow> { + return repository.getCurrencyList(params.base) + } +} + +/** + * The data class to fetch list with base currency + * + * @property base The base currency + */ +data class CurrencyParams( + val base: Rate = Rate.EUR +) : Params() diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/usecase/UseCase.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/usecase/UseCase.kt new file mode 100644 index 0000000..838033a --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/domain/usecase/UseCase.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.domain.usecase + +import io.github.nuhkoca.libbra.data.Result +import kotlinx.coroutines.flow.Flow + +/** + * An intermediate interface between repository and UI layers. + */ +interface UseCase { + + /** + * An intermediate interface to execute Coroutines calls. + * + * @param P The [Params] + * @param T The data class + */ + @FunctionalInterface + interface FlowUseCase : UseCase where P : Params { + + /** + * Executes the call with the given parameters. + * + * @param params The [Params] + * + * @return result within [Flow] builder + */ + fun execute(params: P): Flow> + } +} + +/** + * An abstract class to create parameters in order to hit the service. Any kind of param class + * should be derived from this. + */ +abstract class Params diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/MainActivity.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/MainActivity.kt new file mode 100644 index 0000000..8867e31 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/MainActivity.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupActionBarWithNavController +import io.github.nuhkoca.libbra.R +import io.github.nuhkoca.libbra.databinding.ActivityMainBinding +import io.github.nuhkoca.libbra.util.ext.viewBinding + +class MainActivity : AppCompatActivity() { + + private val binding by viewBinding(ActivityMainBinding::inflate) + + private lateinit var appBarConfiguration: AppBarConfiguration + private val navController by lazy { + val navHostFragment = + // Extension is not working, bug link: https://issuetracker.google.com/issues/142847973 + supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + navHostFragment.navController + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setSupportActionBar(binding.toolbar.toolbar) + appBarConfiguration = AppBarConfiguration.Builder(R.id.currencyFragment).build() + setupActionBarWithNavController(navController, appBarConfiguration) + } + + override fun onSupportNavigateUp(): Boolean { + return navController.navigateUp() || super.onSupportNavigateUp() + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/NavHostFragment.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/NavHostFragment.kt new file mode 100644 index 0000000..d457d75 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/NavHostFragment.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui + +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.FragmentFactory +import androidx.navigation.fragment.NavHostFragment +import io.github.nuhkoca.libbra.ui.di.MainScope +import io.github.nuhkoca.libbra.util.ext.libbraApplication +import javax.inject.Inject + +@MainScope +class NavHostFragment : NavHostFragment() { + + @Inject + lateinit var fragmentFactory: FragmentFactory + + override fun onAttach(context: Context) { + libbraApplication.appComponent.mainComponent().create().inject(this) + super.onAttach(context) + } + + override fun onCreate(savedInstanceState: Bundle?) { + childFragmentManager.fragmentFactory = fragmentFactory + super.onCreate(savedInstanceState) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyAdapter.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyAdapter.kt new file mode 100644 index 0000000..5f2ecfc --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyAdapter.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui.currency + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.github.nuhkoca.libbra.data.model.view.RateViewItem +import io.github.nuhkoca.libbra.databinding.LayoutCurrencyItemBinding +import io.github.nuhkoca.libbra.ui.di.MainScope +import io.github.nuhkoca.libbra.util.event.SingleLiveEvent +import io.github.nuhkoca.libbra.util.recyclerview.BaseViewHolder +import javax.inject.Inject + +@MainScope +class CurrencyAdapter @Inject constructor( + private val itemClickLiveData: SingleLiveEvent +) : ListAdapter(DIFF_CALLBACK) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CurrencyViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = LayoutCurrencyItemBinding.inflate(inflater, parent, false) + return CurrencyViewHolder(binding.root) + } + + override fun onBindViewHolder(holder: CurrencyViewHolder, position: Int) { + val rate = currentList[position] + holder.bindTo(rate) + } + + override fun onBindViewHolder( + holder: CurrencyViewHolder, + position: Int, + payloads: MutableList + ) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + val rate = currentList[position] + holder.bindTo(rate) + } + } + + inner class CurrencyViewHolder(itemView: View) : + BaseViewHolder(itemView) { + + override fun bindTo(item: RateViewItem) { + binding.rate = item + + binding.root.setOnClickListener { + val list = currentList.toMutableList() + list.removeAt(layoutPosition).also { rate -> + list.add(0, rate) + submitList(list) { itemClickLiveData.value = item.abbreviation } + } + } + + super.bindTo(item) + } + } + + private companion object { + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: RateViewItem, newItem: RateViewItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: RateViewItem, newItem: RateViewItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: RateViewItem, newItem: RateViewItem): Any? { + return if (oldItem.id != newItem.id) newItem.id else oldItem.id + } + } + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyFragment.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyFragment.kt new file mode 100644 index 0000000..f7655a1 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyFragment.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui.currency + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.observe +import androidx.navigation.navGraphViewModels +import io.github.nuhkoca.libbra.R +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.databinding.FragmentCurrencyBinding +import io.github.nuhkoca.libbra.ui.di.MainScope +import io.github.nuhkoca.libbra.util.event.SingleLiveEvent +import io.github.nuhkoca.libbra.util.ext.addLifecycleAwareTouchListener +import io.github.nuhkoca.libbra.util.ext.snackBar +import io.github.nuhkoca.libbra.util.ext.viewBinding +import javax.inject.Inject + +@MainScope +class CurrencyFragment @Inject constructor( + private val viewModelFactory: ViewModelProvider.Factory, + private val currencyAdapter: CurrencyAdapter, + private val itemClickLiveData: SingleLiveEvent +) : Fragment(R.layout.fragment_currency) { + + private val binding by viewBinding(FragmentCurrencyBinding::bind) + private val mergedBinding by viewBinding { binding.errorContainer } + private val viewModel: CurrencyViewModel by navGraphViewModels(R.id.nav_graph_main) { viewModelFactory } + + private var animate: Boolean = true + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launchWhenCreated { + itemClickLiveData.observe(viewLifecycleOwner, onChanged = { currency -> + viewModel.setBaseCurrency(Rate.valueOf(currency)) + binding.rvCurrency.scrollToPosition(0) + }) + binding.rvCurrency.setHasFixedSize(true) + binding.rvCurrency.adapter = currencyAdapter + binding.rvCurrency.addLifecycleAwareTouchListener(this@CurrencyFragment) + } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + observeViewModel() + } + + private fun observeViewModel() = with(viewModel) { + currencyLiveData.observe(viewLifecycleOwner, onChanged = { state -> + binding.pbCurrency.isVisible = state.isLoading + mergedBinding.root.isVisible = state.hasError + if (state.hasError) { + state.errorMessage?.let(binding.container::snackBar) + return@observe + } + state.data?.let { + currencyAdapter.submitList(it.rates) + if (animate) binding.rvCurrency.scheduleLayoutAnimation() + animate = false + } + }) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyViewModel.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyViewModel.kt new file mode 100644 index 0000000..d2b7261 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyViewModel.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui.currency + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.view.CurrencyResponseViewItem +import io.github.nuhkoca.libbra.data.succeeded +import io.github.nuhkoca.libbra.domain.usecase.CurrencyParams +import io.github.nuhkoca.libbra.domain.usecase.UseCase +import io.github.nuhkoca.libbra.ui.di.MainScope +import io.github.nuhkoca.libbra.util.coroutines.DispatcherProvider +import io.github.nuhkoca.libbra.util.mapper.Mapper +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +@MainScope +class CurrencyViewModel @Inject constructor( + private val currencyUseCase: @JvmSuppressWildcards UseCase.FlowUseCase, + private val mapper: @JvmSuppressWildcards Mapper, + private val dispatcherProvider: DispatcherProvider +) : ViewModel() { + + private val baseCurrencyLiveData = MutableLiveData() + + private val _currencyLiveData = MutableLiveData() + + val currencyLiveData: LiveData = + Transformations.switchMap(baseCurrencyLiveData, ::getCurrencyList) + + init { + setBaseCurrency(Rate.EUR) + } + + fun setBaseCurrency(base: Rate) = apply { baseCurrencyLiveData.value = base } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getCurrencyList(base: Rate = Rate.EUR): LiveData { + return currencyUseCase.execute(CurrencyParams(base)) + .flatMapLatest { result -> + flow { + if (result.succeeded) { + result as Result.Success + val viewItem = mapper.map(result.data) + emit(currentViewState.copy(data = viewItem, isLoading = false)) + } else { + result as Result.Error + emit( + currentViewState.copy( + isLoading = false, + hasError = true, + errorMessage = result.failure.message + ) + ) + } + } + }.asLiveData(dispatcherProvider.io + viewModelScope.coroutineContext) + } + + private inline val currentViewState: CurrencyViewState + get() = _currencyLiveData.value ?: CurrencyViewState() + + /** + * A data class which represents UI State. + * + * @property data The data to be injected into + * @property isLoading The loading state + * @property hasError The flag indicates error state + * @property errorMessage The error message + */ + data class CurrencyViewState( + val data: CurrencyResponseViewItem? = null, + val isLoading: Boolean = true, + val hasError: Boolean = false, + val errorMessage: String? = null + ) +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/di/MainComponent.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/di/MainComponent.kt new file mode 100644 index 0000000..19d104d --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/di/MainComponent.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui.di + +import dagger.Subcomponent +import io.github.nuhkoca.libbra.ui.NavHostFragment + +@MainScope +@Subcomponent(modules = [MainModule::class]) +interface MainComponent { + + @Subcomponent.Factory + interface Factory { + fun create(): MainComponent + } + + fun inject(navHostFragment: NavHostFragment) +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/di/MainModule.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/di/MainModule.kt new file mode 100644 index 0000000..c503bae --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/ui/di/MainModule.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.github.nuhkoca.libbra.di.factory.FragmentKey +import io.github.nuhkoca.libbra.di.factory.ViewModelKey +import io.github.nuhkoca.libbra.ui.currency.CurrencyAdapter +import io.github.nuhkoca.libbra.ui.currency.CurrencyFragment +import io.github.nuhkoca.libbra.ui.currency.CurrencyViewModel +import io.github.nuhkoca.libbra.util.event.SingleLiveEvent +import javax.inject.Scope + +@Module +internal abstract class MainModule { + + @Binds + @IntoMap + @MainScope + @FragmentKey(CurrencyFragment::class) + internal abstract fun bindCurrencyFragment(currencyFragment: CurrencyFragment): Fragment + + @Binds + @IntoMap + @MainScope + @ViewModelKey(CurrencyViewModel::class) + internal abstract fun bindCurrencyViewModel(viewModel: CurrencyViewModel): ViewModel + + @Module + internal companion object { + + @Provides + @MainScope + internal fun provideCurrencyAdapter( + itemClickListener: SingleLiveEvent + ) = CurrencyAdapter(itemClickListener) + + @Provides + @MainScope + internal fun provideItemClickListener() = SingleLiveEvent() + } +} + +@Scope +@MustBeDocumented +internal annotation class MainScope diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/AsyncManager.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/AsyncManager.kt new file mode 100644 index 0000000..74565d8 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/AsyncManager.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.coroutines + +import io.github.nuhkoca.libbra.data.Result +import kotlinx.coroutines.flow.Flow + +/** + * A main interface to manage asynchronous calls and catch error accordingly. [DefaultAsyncManager] + * implements this interface and this interface shouldn't be implemented directly by any repository. + * Instead, [DefaultAsyncManager] reference should be specified according to the class delegation. + */ +@FunctionalInterface +interface AsyncManager { + + /** + * Handles any asynchronous call and waits for its result. This wrapper also catches errors and + * delivers to upper layer. + * + * @param body The suspend body to be called + * + * @return [T] within [Flow] builder. + */ + fun handleAsyncWithTryCatch( + body: suspend () -> T + ): Flow> +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultAsyncManager.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultAsyncManager.kt new file mode 100644 index 0000000..e47e63e --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultAsyncManager.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.coroutines + +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.failure.Failure +import io.github.nuhkoca.libbra.data.failure.Failure.UnhandledFailure +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flowOn + +/** + * The default implementation of [AsyncManager]. This class is a generic handler and will cover any + * asynchronous calls accordingly. This class should be provided by the class delegation in Kotlin. + * + * @param dispatcher The [DispatcherProvider] to run calls under a specific context + */ +internal class DefaultAsyncManager(private val dispatcher: DispatcherProvider) : AsyncManager { + + /** + * Handles any asynchronous cal and waits for its result. This wrapper also catches errors and + * delivers to upper layer. + * + * @param body a suspend body + * + * @return [T] with [Result] wrapper. + */ + @ExperimentalCoroutinesApi + override fun handleAsyncWithTryCatch(body: suspend () -> T): Flow> { + return channelFlow> { + while (!isClosedForSend) { + delay(DELAY_IN_MS) + send(Result.Success(body())) + } + invokeOnClose { close() } + }.catch { e -> + when (e) { + is Failure -> emit(Result.Error(e)) + else -> emit(Result.Error(UnhandledFailure(e.message.toString()))) + } + }.flowOn(dispatcher.io) + } + + private companion object { + private const val DELAY_IN_MS = 1000L + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultDispatcherProvider.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultDispatcherProvider.kt new file mode 100644 index 0000000..f346aa9 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultDispatcherProvider.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.coroutines + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Coroutines dispatcher implementation for [Dispatchers]. Processes will be started inside the + * specified thread by this class. + */ +@Singleton +internal class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider { + + /** + * Binds [Dispatchers.Main] by default. + */ + override val main: CoroutineDispatcher + get() = Dispatchers.Main + + /** + * Binds [Dispatchers.Default] by default. + */ + override val default: CoroutineDispatcher + get() = Dispatchers.Default + + /** + * Binds [Dispatchers.IO] by default. + */ + override val io: CoroutineDispatcher + get() = Dispatchers.IO + + /** + * Binds [Dispatchers.Unconfined] by default. + */ + @ExperimentalCoroutinesApi + override val unconfined: CoroutineDispatcher + get() = Dispatchers.Unconfined +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DispatcherProvider.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DispatcherProvider.kt new file mode 100644 index 0000000..212e38c --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/coroutines/DispatcherProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.coroutines + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * A main interface that manages Coroutines contex. This class also provides a crucial benefit for + * testing. + */ +interface DispatcherProvider { + + /** + * Dispatcher definition for [Dispatchers.Main] + */ + val main: CoroutineDispatcher + + /** + * Dispatcher definition for [Dispatchers.Default]. This thread should be used for heavy background + * processes. + */ + val default: CoroutineDispatcher + + /** + * Dispatcher definition for [Dispatchers.IO]. This thread should be used for IO processes. + */ + val io: CoroutineDispatcher + + /** + * Dispatcher definition for [Dispatchers.Unconfined] + */ + val unconfined: CoroutineDispatcher +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/ActivityViewBindingDelegate.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/ActivityViewBindingDelegate.kt new file mode 100644 index 0000000..0ea4501 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/ActivityViewBindingDelegate.kt @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.delegates + +import android.view.LayoutInflater +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * A [ReadOnlyProperty] to create a [ViewBinding] for requested [AppCompatActivity] + * + * @param activity The activity that has [ViewBinding] enabled + * @param viewBindingFactory The factory to initialize binding + */ +class ActivityViewBindingDelegate( + private val activity: AppCompatActivity, + private val viewBindingFactory: (LayoutInflater) -> T +) : ReadOnlyProperty, LifecycleEventObserver { + private var binding: T? = null + + init { + activity.lifecycle.addObserver(this) + } + + override fun getValue(thisRef: AppCompatActivity, property: KProperty<*>): T { + val binding = binding + if (binding != null) { + return binding + } + + val lifecycle = activity.lifecycle + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { + throw IllegalStateException("Should not attempt to get bindings when Activity views are destroyed.") + } + + return viewBindingFactory(thisRef.layoutInflater).also { + this@ActivityViewBindingDelegate.binding = it + } + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + binding = null + activity.lifecycle.removeObserver(this) + } + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/FragmentViewBindingDelegate.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/FragmentViewBindingDelegate.kt new file mode 100644 index 0000000..ef115cf --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/FragmentViewBindingDelegate.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.delegates + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.observe +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * A [ReadOnlyProperty] to create a [ViewBinding] for requested [Fragment] + * + * @param fragment The fragment that has [ViewBinding] enabled + * @param viewBindingFactory The factory to initialize binding + */ +class FragmentViewBindingDelegate( + private val fragment: Fragment, + private val viewBindingFactory: (View) -> T +) : ReadOnlyProperty { + private var binding: T? = null + + init { + fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observe( + fragment, + onChanged = { viewLifecycleOwner -> + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + fragment.lifecycle.removeObserver(this) + } + }) + } + ) + } + }) + } + + override fun getValue(thisRef: Fragment, property: KProperty<*>): T { + val binding = binding + if (binding != null) { + return binding + } + + val lifecycle = fragment.viewLifecycleOwner.lifecycle + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { + throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") + } + + return viewBindingFactory(thisRef.requireView()).also { + this@FragmentViewBindingDelegate.binding = it + } + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/LifecycleAwareTouchListener.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/LifecycleAwareTouchListener.kt new file mode 100644 index 0000000..7e6aaa0 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/LifecycleAwareTouchListener.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.delegates + +import android.view.MotionEvent +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.observe +import androidx.recyclerview.widget.RecyclerView +import io.github.nuhkoca.libbra.util.ext.hideKeyboard +import io.github.nuhkoca.libbra.util.recyclerview.DefaultItemTouchListener + +/** + * A lifecycle aware touch listener for [RecyclerView]. Touch listener is bound to target fragment's + * lifecycle so that it will be handled properly. + * + * @param fragment The Fragment to bound this listener to its lifecycle + * @param recyclerView The RecyclerView to add touch listener + */ +class LifecycleAwareTouchListener( + private val fragment: Fragment, + private val recyclerView: RecyclerView +) { + + private val defaultItemTouchListener = object : DefaultItemTouchListener() { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + fragment.hideKeyboard() + return super.onInterceptTouchEvent(rv, e) + } + } + + init { + fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + recyclerView.addOnItemTouchListener(defaultItemTouchListener) + fragment.viewLifecycleOwnerLiveData.observe( + fragment, + onChanged = { viewLifecycleOwner -> + viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + recyclerView.removeOnItemTouchListener(defaultItemTouchListener) + } + }) + } + ) + } + }) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/ViewHolderBindingDelegate.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/ViewHolderBindingDelegate.kt new file mode 100644 index 0000000..9d25e81 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/delegates/ViewHolderBindingDelegate.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.delegates + +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * A [ReadOnlyProperty] to create a [ViewDataBinding] for requested [RecyclerView.ViewHolder] + * + * @param viewHolder The ViewHolder that has [ViewDataBinding] enabled + */ +class ViewHolderBindingDelegate( + private val viewHolder: RecyclerView.ViewHolder +) : ReadOnlyProperty { + private var binding: T? = null + + override fun getValue(thisRef: RecyclerView.ViewHolder, property: KProperty<*>): T { + val binding = binding + if (binding != null) { + return binding + } + + val dataBinding = DataBindingUtil.getBinding(viewHolder.itemView) + ?: throw IllegalStateException("The view is not a root View for a layout or view hasn't been bound.") + + return dataBinding.also { this.binding = it } + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/event/SingleLiveEvent.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/event/SingleLiveEvent.kt new file mode 100644 index 0000000..8bf9f06 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/event/SingleLiveEvent.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.event + +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import io.github.nuhkoca.libbra.util.ext.w +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A lifecycle-aware observable that sends only new updates after subscription, used for events like + * navigation and Snackbar messages. + * + * + * This avoids a common problem with events: on configuration change (like rotation) an update + * can be emitted if the observer is active. This LiveData only calls the observable if there's an + * explicit call to setValue() or call(). + * + * + * Note that only one observer is going to be notified of changes. + */ +class SingleLiveEvent : MutableLiveData() { + private val pending = AtomicBoolean(false) + + @MainThread + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + w { "Multiple observers registered but only one will be notified of changes." } + } + // Observe the internal MutableLiveData + super.observe(owner, Observer { t -> + if (pending.compareAndSet(true, false)) { + observer.onChanged(t) + } + }) + } + + @MainThread + override fun setValue(t: T?) { + pending.set(true) + super.setValue(t) + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Activity.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Activity.kt new file mode 100644 index 0000000..88dfba9 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Activity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("ActivityKt") + +package io.github.nuhkoca.libbra.util.ext + +import android.app.Activity +import android.content.Context.INPUT_METHOD_SERVICE +import android.view.inputmethod.InputMethodManager + +/** + * Hides keyboard from the UI. + */ +fun Activity.hideKeyboard() { + val currentFocus = currentFocus + currentFocus?.windowToken?.let { windowToken -> + + val inputManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + inputManager.hideSoftInputFromWindow(windowToken, 0) + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Delegates.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Delegates.kt new file mode 100644 index 0000000..26c2e7b --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Delegates.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("DelegatesKt") + +package io.github.nuhkoca.libbra.util.ext + +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.ViewDataBinding +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import io.github.nuhkoca.libbra.util.delegates.ActivityViewBindingDelegate +import io.github.nuhkoca.libbra.util.delegates.FragmentViewBindingDelegate +import io.github.nuhkoca.libbra.util.delegates.ViewHolderBindingDelegate + +/** + * A delegation function that initializes [ViewBinding] for desired [Fragment] + * + * @param viewBindingFactory The factory to initialize binding + */ +fun Fragment.viewBinding( + viewBindingFactory: (View) -> T +) = FragmentViewBindingDelegate(this, viewBindingFactory) + +/** + * A delegation function that initializes [ViewBinding] for desired [AppCompatActivity] + * + * @param bindingInflater The inflater to inflate view + */ +fun AppCompatActivity.viewBinding( + bindingInflater: (LayoutInflater) -> T +) = ActivityViewBindingDelegate(this, bindingInflater) + +/** + * A delegation function that initializes [ViewDataBinding] for desired [RecyclerView.ViewHolder] + */ +fun RecyclerView.ViewHolder.viewHolderBinding() = + ViewHolderBindingDelegate(this) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Fragment.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Fragment.kt new file mode 100644 index 0000000..f2679e2 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Fragment.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("FragmentKt") + +package io.github.nuhkoca.libbra.util.ext + +import androidx.fragment.app.Fragment +import io.github.nuhkoca.libbra.LibbraApplication + +/** + * An inline value that takes [Fragment]'s application as [LibbraApplication] + */ +inline val Fragment.libbraApplication: LibbraApplication + get() = (requireActivity().application as LibbraApplication) + +/** + * Hides keyboard from the UI. + */ +fun Fragment.hideKeyboard() = requireActivity().hideKeyboard() diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Interceptors.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Interceptors.kt new file mode 100644 index 0000000..6dbf95d --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Interceptors.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("unused") +@file:JvmName("InterceptorsKt") + +package io.github.nuhkoca.libbra.util.ext + +import io.github.nuhkoca.libbra.data.failure.ErrorResponse +import io.github.nuhkoca.libbra.data.failure.Failure +import io.github.nuhkoca.libbra.data.failure.Failure.CancellationFailure +import io.github.nuhkoca.libbra.data.failure.Failure.SerializationFailure +import io.github.nuhkoca.libbra.data.failure.Failure.ServerFailure +import io.github.nuhkoca.libbra.data.failure.Failure.UnhandledFailure +import kotlinx.serialization.SerializationException +import kotlinx.serialization.UnstableDefault +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecodingException +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import java.io.IOException +import java.util.concurrent.CancellationException + +@UnstableDefault +fun OkHttpClient.Builder.errorInterceptor() = ErrorInterceptor() + +/** + * An [Interceptor] implementation for error handling. + */ +@UnstableDefault +class ErrorInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + try { + // This also might throw IOException + return chain.proceed(chain.request()).apply(::checkError) + } catch (f: Failure) { + throw f + } catch (e: IOException) { + throw UnhandledFailure(e.message.toString()) + } + } + + /** + * Detects any kind of error and handle these for all requests within the module. + * + * @param response the network response + * + * @throws IOException + * @throws SerializationException + */ + @Suppress("TooGenericExceptionCaught", "ThrowsCount") + @UnstableDefault + @Throws(IOException::class, SerializationException::class) + private fun checkError(response: Response) { + if (response.isSuccessful) return + + val errorBody = response.body ?: return + /* Since string() loads entire response body into memory we should call body and string() + * functions separately. + */ + val errorString = errorBody.string() + + try { + val errorResponse = Json.parse(ErrorResponse.serializer(), errorString) + throw ServerFailure(errorResponse.message) + } catch (e: Exception) { + when (e) { + is SerializationException, is JsonDecodingException -> { + throw SerializationFailure(e.message.toString()) + } + is ServerFailure -> throw ServerFailure(e.message) + is CancellationException -> throw CancellationFailure(e.message.toString()) + else -> throw UnhandledFailure(e.message.toString()) + } + } finally { + response.close() + } + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/RecyclerView.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/RecyclerView.kt new file mode 100644 index 0000000..e9af686 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/RecyclerView.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("RecyclerViewKt") + +package io.github.nuhkoca.libbra.util.ext + +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import io.github.nuhkoca.libbra.util.delegates.LifecycleAwareTouchListener + +/** + * Adds lifecycle aware touch listener to target [Fragment]. + * + * @param fragment The target fragment + */ +fun RecyclerView.addLifecycleAwareTouchListener(fragment: Fragment) { + LifecycleAwareTouchListener(fragment, this) +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Snackbar.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Snackbar.kt new file mode 100644 index 0000000..443f2f4 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Snackbar.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("SnackbarKt") + +package io.github.nuhkoca.libbra.util.ext + +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.updateMargins +import com.google.android.material.snackbar.Snackbar + +/** + * An extension file for [Snackbar] + */ +private const val SNACKBAR_MAX_LINES_DEFAULT = 3 + +/** + * Fixes [Snackbar]'s view at a fixed size especially while in fullscreen mode. + */ +private fun View.setFixedSize() { + // Have Snackbar at fixed size when display is fullscreen + setOnApplyWindowInsetsListener { v, insets -> + v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, v.paddingTop) + + val params = v.layoutParams as ViewGroup.MarginLayoutParams + params.updateMargins( + params.leftMargin, + params.topMargin, + params.rightMargin, + params.bottomMargin + insets.systemWindowInsetBottom + ) + v.layoutParams = params + + insets + } +} + +/** + * Show snackbar with a String message + * + * @param view target + * @param message a String message + */ +fun showSnackbar(view: View, message: String) { + val trimmedMessage = message.trimStart().trimEnd() + val snackbar = Snackbar.make(view, trimmedMessage, Snackbar.LENGTH_LONG) + val snackbarView = snackbar.view + + val textView = + snackbarView.findViewById(com.google.android.material.R.id.snackbar_text) as TextView + textView.maxLines = SNACKBAR_MAX_LINES_DEFAULT + + // Have Snackbar at fixed size when display is fullscreen + snackbarView.setFixedSize() + snackbar.show() +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Timber.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Timber.kt new file mode 100644 index 0000000..dc9cbd4 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/Timber.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("TimberKt") + +package io.github.nuhkoca.libbra.util.ext + +import android.annotation.SuppressLint +import timber.log.Timber + +/** + * Extension function that wraps [Timber]'s debug logging. + * + * @param message The message block + */ +@SuppressLint("TimberLogDetector") +inline fun d(crossinline message: () -> String) = log { Timber.d(message()) } + +/** + * Extension function that wraps [Timber]'s info logging. + * + * @param message The message block + */ +@SuppressLint("TimberLogDetector") +inline fun i(crossinline message: () -> String) = log { Timber.i(message()) } + +/** + * Extension function that wraps [Timber]'s warning logging. + * + * @param message The message block + */ +@SuppressLint("TimberLogDetector") +inline fun w(crossinline message: () -> String) = log { Timber.w(message()) } + +/** + * Extension function that wraps [Timber]'s error logging. + * + * @param message The message block + */ +@SuppressLint("TimberLogDetector") +inline fun e(crossinline message: () -> String) = log { Timber.e(message()) } + +/** @suppress */ +@PublishedApi +internal inline fun log(block: () -> Unit) { + if (Timber.treeCount() > 0) block() +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/View.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/View.kt new file mode 100644 index 0000000..da7c9f1 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/ext/View.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.ext + +import android.view.View +import com.google.android.material.snackbar.Snackbar + +/** + * Show a [Snackbar] on the target view. + * + * @param message a String message + */ +fun View.snackBar(message: String) = showSnackbar(this, message) diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/mapper/Mapper.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/mapper/Mapper.kt new file mode 100644 index 0000000..81a84dd --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/mapper/Mapper.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.mapper + +/** + * An interface to map given input to desired output. + */ +@FunctionalInterface +interface Mapper { + + /** + * A suspend function that maps given input to desired output. + * + * @param item The input + * + * @return [R] + */ + suspend fun map(item: T): R +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/recyclerview/BaseViewHolder.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/recyclerview/BaseViewHolder.kt new file mode 100644 index 0000000..c4c32b7 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/recyclerview/BaseViewHolder.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.recyclerview + +import android.view.View +import androidx.annotation.CallSuper +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView +import io.github.nuhkoca.libbra.util.ext.viewHolderBinding + +/** + * An open base class for [RecyclerView.ViewHolder] to initialize binding and execute accordingly. + * + * @param itemView The root view + */ +open class BaseViewHolder(itemView: View) : + RecyclerView.ViewHolder(itemView) { + + internal val binding: DB by viewHolderBinding() + + /** + * Binds given item to this binding + * + * @param item represents the data + */ + @CallSuper + open fun bindTo(item: T) { + if (binding.hasPendingBindings()) { + binding.executePendingBindings() + } + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/recyclerview/DefaultItemTouchListener.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/recyclerview/DefaultItemTouchListener.kt new file mode 100644 index 0000000..df6d3f7 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/recyclerview/DefaultItemTouchListener.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.recyclerview + +import android.view.MotionEvent +import androidx.recyclerview.widget.RecyclerView + +/** + * Default [RecyclerView.OnItemTouchListener] implementation that eliminates unused callback + * boilerplate from UI. + */ +open class DefaultItemTouchListener : RecyclerView.OnItemTouchListener { + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + // no-op + } + + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + return false + } + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + // no-op + } +} diff --git a/app/src/main/kotlin/io/github/nuhkoca/libbra/util/widget/CurrencyEditText.kt b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/widget/CurrencyEditText.kt new file mode 100644 index 0000000..8f9a0f2 --- /dev/null +++ b/app/src/main/kotlin/io/github/nuhkoca/libbra/util/widget/CurrencyEditText.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.widget + +import android.content.Context +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import com.google.android.material.textfield.TextInputEditText +import java.text.DecimalFormat +import java.text.NumberFormat +import java.text.ParseException +import java.util.* + +/** + * A custom [TextInputEditText] implementation which is designed for currency handling according to + * the current locale. + * + * @param context The context + * @param attributeSet The attribute set for the view + */ +class CurrencyEditText @JvmOverloads constructor( + context: Context, + attributeSet: AttributeSet? = null +) : TextInputEditText(context, attributeSet) { + + private var isEditing = false + + private val textWatcher = object : TextWatcher { + + @Synchronized + override fun afterTextChanged(s: Editable) { + if (isEditing) return + isEditing = true + val formattedAmount = parseText(s.toString()) + setText(formatText(formattedAmount)) + setSelection(length()) + isEditing = false + } + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + if (isEditing) return + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + if (isEditing) return + } + } + + /** + * Parses given string to desired number as a currency + * + * @param text The text to be parsed + * + * @return [Number] + */ + private fun parseText(text: String): Number? { + return try { + formatter.parse(text) + } catch (e: ParseException) { + 1 + } + } + + /** + * Formats given number to desired text + * + * @param number The number to be formatted + * + * @return [String] + */ + private fun formatText(number: Number?) = formatter.format(number) + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + addTextChangedListener(textWatcher) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + removeTextChangedListener(textWatcher) + } + + private companion object { + private inline val formatter: NumberFormat + get() = DecimalFormat.getInstance(Locale.getDefault()) + } +} diff --git a/app/src/main/res/anim/item_animation_fall_down.xml b/app/src/main/res/anim/item_animation_fall_down.xml new file mode 100644 index 0000000..68b0021 --- /dev/null +++ b/app/src/main/res/anim/item_animation_fall_down.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/anim/layout_animation_fall_down.xml b/app/src/main/res/anim/layout_animation_fall_down.xml new file mode 100644 index 0000000..2fc1100 --- /dev/null +++ b/app/src/main/res/anim/layout_animation_fall_down.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/color/currency_edit_text_selector.xml b/app/src/main/res/color/currency_edit_text_selector.xml new file mode 100644 index 0000000..35cf571 --- /dev/null +++ b/app/src/main/res/color/currency_edit_text_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..0c0e9ff --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,59 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_aud.xml b/app/src/main/res/drawable/ic_aud.xml new file mode 100644 index 0000000..5df7248 --- /dev/null +++ b/app/src/main/res/drawable/ic_aud.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_bgn.xml b/app/src/main/res/drawable/ic_bgn.xml new file mode 100644 index 0000000..bf201b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_bgn.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_brl.xml b/app/src/main/res/drawable/ic_brl.xml new file mode 100644 index 0000000..0ddb920 --- /dev/null +++ b/app/src/main/res/drawable/ic_brl.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_cad.xml b/app/src/main/res/drawable/ic_cad.xml new file mode 100644 index 0000000..0abc883 --- /dev/null +++ b/app/src/main/res/drawable/ic_cad.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_chf.xml b/app/src/main/res/drawable/ic_chf.xml new file mode 100644 index 0000000..c883340 --- /dev/null +++ b/app/src/main/res/drawable/ic_chf.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_cny.xml b/app/src/main/res/drawable/ic_cny.xml new file mode 100644 index 0000000..4039527 --- /dev/null +++ b/app/src/main/res/drawable/ic_cny.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_czk.xml b/app/src/main/res/drawable/ic_czk.xml new file mode 100644 index 0000000..3d6e91d --- /dev/null +++ b/app/src/main/res/drawable/ic_czk.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_dkk.xml b/app/src/main/res/drawable/ic_dkk.xml new file mode 100644 index 0000000..c039d3a --- /dev/null +++ b/app/src/main/res/drawable/ic_dkk.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_eur.xml b/app/src/main/res/drawable/ic_eur.xml new file mode 100644 index 0000000..c89c69e --- /dev/null +++ b/app/src/main/res/drawable/ic_eur.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_gbp.xml b/app/src/main/res/drawable/ic_gbp.xml new file mode 100644 index 0000000..2de019f --- /dev/null +++ b/app/src/main/res/drawable/ic_gbp.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_hkd.xml b/app/src/main/res/drawable/ic_hkd.xml new file mode 100644 index 0000000..e8baf94 --- /dev/null +++ b/app/src/main/res/drawable/ic_hkd.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_hrk.xml b/app/src/main/res/drawable/ic_hrk.xml new file mode 100644 index 0000000..d72520b --- /dev/null +++ b/app/src/main/res/drawable/ic_hrk.xml @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_huf.xml b/app/src/main/res/drawable/ic_huf.xml new file mode 100644 index 0000000..4e0a922 --- /dev/null +++ b/app/src/main/res/drawable/ic_huf.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_idr.xml b/app/src/main/res/drawable/ic_idr.xml new file mode 100644 index 0000000..d391082 --- /dev/null +++ b/app/src/main/res/drawable/ic_idr.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_ils.xml b/app/src/main/res/drawable/ic_ils.xml new file mode 100644 index 0000000..b6c37ee --- /dev/null +++ b/app/src/main/res/drawable/ic_ils.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_inr.xml b/app/src/main/res/drawable/ic_inr.xml new file mode 100644 index 0000000..2df3726 --- /dev/null +++ b/app/src/main/res/drawable/ic_inr.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_isk.xml b/app/src/main/res/drawable/ic_isk.xml new file mode 100644 index 0000000..033f7c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_isk.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_jpy.xml b/app/src/main/res/drawable/ic_jpy.xml new file mode 100644 index 0000000..42a13cc --- /dev/null +++ b/app/src/main/res/drawable/ic_jpy.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_krw.xml b/app/src/main/res/drawable/ic_krw.xml new file mode 100644 index 0000000..57fac54 --- /dev/null +++ b/app/src/main/res/drawable/ic_krw.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0c0e9ff --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,59 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mxn.xml b/app/src/main/res/drawable/ic_mxn.xml new file mode 100644 index 0000000..4d92bdd --- /dev/null +++ b/app/src/main/res/drawable/ic_mxn.xml @@ -0,0 +1,759 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_myr.xml b/app/src/main/res/drawable/ic_myr.xml new file mode 100644 index 0000000..631a10a --- /dev/null +++ b/app/src/main/res/drawable/ic_myr.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_nok.xml b/app/src/main/res/drawable/ic_nok.xml new file mode 100644 index 0000000..4ad70a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_nok.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_nzd.xml b/app/src/main/res/drawable/ic_nzd.xml new file mode 100644 index 0000000..d78caab --- /dev/null +++ b/app/src/main/res/drawable/ic_nzd.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_php.xml b/app/src/main/res/drawable/ic_php.xml new file mode 100644 index 0000000..76c5300 --- /dev/null +++ b/app/src/main/res/drawable/ic_php.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pln.xml b/app/src/main/res/drawable/ic_pln.xml new file mode 100644 index 0000000..6bf8c91 --- /dev/null +++ b/app/src/main/res/drawable/ic_pln.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_ron.xml b/app/src/main/res/drawable/ic_ron.xml new file mode 100644 index 0000000..28bd1a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_ron.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_rub.xml b/app/src/main/res/drawable/ic_rub.xml new file mode 100644 index 0000000..01a0c34 --- /dev/null +++ b/app/src/main/res/drawable/ic_rub.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_sek.xml b/app/src/main/res/drawable/ic_sek.xml new file mode 100644 index 0000000..4df7dbd --- /dev/null +++ b/app/src/main/res/drawable/ic_sek.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_sgd.xml b/app/src/main/res/drawable/ic_sgd.xml new file mode 100644 index 0000000..b330139 --- /dev/null +++ b/app/src/main/res/drawable/ic_sgd.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_thb.xml b/app/src/main/res/drawable/ic_thb.xml new file mode 100644 index 0000000..43603f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_thb.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_unknown.xml b/app/src/main/res/drawable/ic_unknown.xml new file mode 100644 index 0000000..fd0c379 --- /dev/null +++ b/app/src/main/res/drawable/ic_unknown.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_usd.xml b/app/src/main/res/drawable/ic_usd.xml new file mode 100644 index 0000000..d0b09d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_usd.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_zar.xml b/app/src/main/res/drawable/ic_zar.xml new file mode 100644 index 0000000..3f308c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_zar.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/font/montserrat.xml b/app/src/main/res/font/montserrat.xml new file mode 100644 index 0000000..1f2f8e5 --- /dev/null +++ b/app/src/main/res/font/montserrat.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/font/montserrat_bold.xml b/app/src/main/res/font/montserrat_bold.xml new file mode 100644 index 0000000..3eb178d --- /dev/null +++ b/app/src/main/res/font/montserrat_bold.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..952225d --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/app/src/main/res/layout/fragment_currency.xml b/app/src/main/res/layout/fragment_currency.xml new file mode 100644 index 0000000..311d547 --- /dev/null +++ b/app/src/main/res/layout/fragment_currency.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_currency_item.xml b/app/src/main/res/layout/layout_currency_item.xml new file mode 100644 index 0000000..79ba345 --- /dev/null +++ b/app/src/main/res/layout/layout_currency_item.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_custom_toolbar.xml b/app/src/main/res/layout/layout_custom_toolbar.xml new file mode 100644 index 0000000..a243f87 --- /dev/null +++ b/app/src/main/res/layout/layout_custom_toolbar.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/layout/layout_error_view.xml b/app/src/main/res/layout/layout_error_view.xml new file mode 100644 index 0000000..f0b7a25 --- /dev/null +++ b/app/src/main/res/layout/layout_error_view.xml @@ -0,0 +1,35 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b9b483e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..3ba54c6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..7bc199f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..b2e4b16 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..ddc5cb9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..6323472 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0d95e00 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..016ca0c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..dd9a918 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8dd20db Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml new file mode 100644 index 0000000..8d63b3a --- /dev/null +++ b/app/src/main/res/navigation/nav_graph_main.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/raw/empty.json b/app/src/main/res/raw/empty.json new file mode 100644 index 0000000..f79bf38 --- /dev/null +++ b/app/src/main/res/raw/empty.json @@ -0,0 +1 @@ +{"v":"4.7.0","fr":25,"ip":0,"op":50,"w":120,"h":120,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ruoi","ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.967]},"o":{"x":[0.167],"y":[0.033]},"n":["0p833_0p967_0p167_0p033"],"t":35,"s":[100],"e":[0]},{"t":49}]},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0,"y":0},"n":"0p833_0p833_0_0","t":0,"s":[57.361,61.016,0],"e":[57.699,41.796,0],"to":[-4.67500305175781,-4.12800598144531,0],"ti":[-13.9099960327148,5.27300262451172,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":10.219,"s":[57.699,41.796,0],"e":[79.084,33.982,0],"to":[12.8159942626953,-4.85800170898438,0],"ti":[-4.54498291015625,3.73400115966797,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":19.445,"s":[79.084,33.982,0],"e":[59.691,9.121,0],"to":[6.61601257324219,-5.43799591064453,0],"ti":[20.0290069580078,1.20700073242188,0]},{"t":35}]},"a":{"a":0,"k":[60.531,10.945,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.994,0],[0,-0.994],[0.995,0],[0,0.994]],"o":[[0.995,0],[0,0.994],[-0.994,0],[0,-0.994]],"v":[[-0.001,-1.801],[1.801,-0.001],[-0.001,1.801],[-1.801,-0.001]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.529,0.529,0.529,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[62.4,13.144],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.422,0],[0,-1.422],[1.421,0],[0,1.422]],"o":[[1.421,0],[0,1.422],[-1.422,0],[0,-1.422]],"v":[[0.001,-2.574],[2.574,0],[0.001,2.574],[-2.574,0]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"st","c":{"a":0,"k":[0.529,0.529,0.529,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0.7},"lc":1,"lj":1,"ml":10,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[64.145,9.606],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"ix":2,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.996,0],[0,-1.996],[1.996,0],[0,1.996]],"o":[[1.996,0],[0,1.996],[-1.996,0],[0,-1.996]],"v":[[0,-3.614],[3.614,0],[0,3.614],[-3.614,0]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"st","c":{"a":0,"k":[0.529,0.529,0.529,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0.7},"lc":1,"lj":1,"ml":10,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[57.957,10.552],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"ix":3,"mn":"ADBE Vector Group"},{"ty":"tr","p":{"a":0,"k":[60.531,10.941],"ix":2},"a":{"a":0,"k":[60.531,10.941],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"ruoi","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":50,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.967]},"o":{"x":[0.167],"y":[0.033]},"n":["0p833_0p967_0p167_0p033"],"t":35,"s":[100],"e":[0]},{"t":49}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[-0.75,-0.75,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-13.91,5.273],[-4.545,3.734],[20.029,1.207]],"o":[[-4.675,-4.128],[12.816,-4.858],[6.616,-5.438],[0,0]],"v":[[-7.383,24.76],[-7.046,5.54],[14.34,-2.273],[-3.178,-24.76]],"c":false}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"st","c":{"a":0,"k":[0.627,0.627,0.627,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1},"lc":2,"lj":2,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":2.028}},{"n":"g","nm":"gap","v":{"a":0,"k":2.028}},{"n":"o","nm":"offset","v":{"a":0,"k":0}}],"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"tr","p":{"a":0,"k":[67.87,37.631],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.953]},"o":{"x":[0.167],"y":[0.033]},"n":["0p833_0p953_0p167_0p033"],"t":0,"s":[0],"e":[100]},{"t":35}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim"}],"ip":0,"op":50,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":3,"ty":4,"nm":"im_emptyBox Outlines","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[60,60,0]},"a":{"a":0,"k":[60,60,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.001,-16.607],[-32.143,-0.002],[-0.001,16.607],[32.144,-0.002]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.8,0.82,0.851,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[60,55.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[12.856,-23.249],[0,-16.605],[-12.857,-23.249],[-45,-6.641],[-32.144,0.001],[-45,6.645],[-12.857,23.249],[0,16.609],[12.856,23.249],[45,6.645],[32.143,0.001],[45,-6.641]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.957,0.957,0.957,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[60,55.748],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-16.072,24.171],[16.072,11.312],[16.072,-24.171],[-16.072,-24.171]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.902,0.914,0.929,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[76.072,83.33],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-32.143,-24.171],[-32.143,11.311],[-0.001,24.171],[32.144,11.311],[32.144,-24.171]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.8,0.82,0.851,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[60,83.33],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":2,"cix":2,"ix":4,"mn":"ADBE Vector Group"},{"ty":"tr","p":{"a":0,"k":[60,60.186],"ix":2},"a":{"a":0,"k":[60,60.186],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"box","np":4,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":50,"st":0,"bm":0,"sr":1}]} \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..56bb043 --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #FFFFFF + diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..6ecbec8 --- /dev/null +++ b/app/src/main/res/values-night/styles.xml @@ -0,0 +1,6 @@ + + + + + + + + + + + + + + diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/UnitTestSuite.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/UnitTestSuite.kt new file mode 100644 index 0000000..51e4b88 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/UnitTestSuite.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra + +import io.github.nuhkoca.libbra.data.datasource.CurrencyRemoteDataSourceTest +import io.github.nuhkoca.libbra.data.mapper.CurrencyDomainMapperTest +import io.github.nuhkoca.libbra.data.serializers.RateSerializerTest +import io.github.nuhkoca.libbra.data.service.CurrencyServiceTest +import io.github.nuhkoca.libbra.data.verifier.RevolutHostnameVerifierTest +import io.github.nuhkoca.libbra.domain.mapper.CurrencyViewItemMapperTest +import io.github.nuhkoca.libbra.domain.repository.CurrencyRepositoryTest +import io.github.nuhkoca.libbra.domain.usecase.CurrencyUseCaseTest +import io.github.nuhkoca.libbra.ui.currency.CurrencyViewModelTest +import io.github.nuhkoca.libbra.util.coroutines.DefaultAsyncManagerTest +import org.junit.runner.RunWith +import org.junit.runners.Suite + +/** + * A unit test suite to execute all the test classes under this module. + */ +@RunWith(Suite::class) +@Suite.SuiteClasses( + CurrencyRemoteDataSourceTest::class, + CurrencyDomainMapperTest::class, + RateSerializerTest::class, + CurrencyServiceTest::class, + RevolutHostnameVerifierTest::class, + CurrencyViewItemMapperTest::class, + CurrencyRepositoryTest::class, + CurrencyUseCaseTest::class, + CurrencyViewModelTest::class, + DefaultAsyncManagerTest::class +) +object UnitTestSuite diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/data/datasource/CurrencyRemoteDataSourceTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/datasource/CurrencyRemoteDataSourceTest.kt new file mode 100644 index 0000000..a4a2375 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/datasource/CurrencyRemoteDataSourceTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.datasource + +import BaseTestClass +import androidx.test.filters.MediumTest +import com.google.common.truth.Truth.assertThat +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.raw.CurrencyResponseRaw +import io.github.nuhkoca.libbra.data.service.CurrencyService +import io.github.nuhkoca.libbra.data.shared.rule.CoroutinesTestRule +import io.github.nuhkoca.libbra.shared.assertion.test +import io.github.nuhkoca.libbra.shared.ext.runBlockingTest +import io.github.nuhkoca.libbra.util.mapper.Mapper +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +/** + * A test rule for [CurrencyRemoteDataSource] + */ +@RunWith(MockitoJUnitRunner::class) +@MediumTest +class CurrencyRemoteDataSourceTest : BaseTestClass() { + + /* + ------------ + | Rules | + ------------ + */ + @ExperimentalCoroutinesApi + @get:Rule + val coroutinesTestRule = CoroutinesTestRule() + + /* + ------------- + | Mocks | + ------------- + */ + @MockK + private lateinit var currencyService: CurrencyService + + @MockK + private lateinit var mapper: Mapper + + @RelaxedMockK + private lateinit var currencyResponseRaw: CurrencyResponseRaw + + @RelaxedMockK + private lateinit var currencyResponse: CurrencyResponse + + /* + ----------------------- + | Private members | + ----------------------- + */ + private lateinit var dataSource: DataSource + private val currencySlot = slot() + + @ExperimentalCoroutinesApi + override fun setUp() { + super.setUp() + + coEvery { currencyService.getCurrencyList(capture(currencySlot)) } returns currencyResponseRaw + coEvery { mapper.map(any()) } returns currencyResponse + + every { currencyResponse.baseCurrency } returns "CNY" + + dataSource = CurrencyRemoteDataSource( + currencyService, + mapper, + coroutinesTestRule.testDispatcherProvider + ) + } + + @Test + @ExperimentalCoroutinesApi + fun `data source should return data`() = coroutinesTestRule.runBlockingTest { + // Given + val base = Rate.CNY + + // When + val flow = dataSource.getCurrencyList(base) + + // Then + flow.test { + expectItem().run { + assertThat(this).isNotNull() + assertThat(this).isInstanceOf(Result.Success::class.java) + this as Result.Success + assertThat(data.baseCurrency).isEqualTo(currencySlot.captured.name) + assertThat(data.rates).isNotNull() + } + cancel() + expectNoMoreEvents() + } + + /* + * Verify order of calls + * e.g. first currencyService should be called and then mapper + */ + coVerifyOrder { + currencyService.getCurrencyList(any()) + mapper.map(any()) + } + + confirmVerified(currencyService) + confirmVerified(mapper) + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/data/enums/RateTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/enums/RateTest.kt new file mode 100644 index 0000000..bd2f067 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/enums/RateTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.enums + +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +/** + * A parameterized test class for [Rate] + */ +@SmallTest +class RateTest { + + /** + * A parameterized test function that iterates and performs test on all rates. + * + * @param rate represents any [Rate] + */ + @ParameterizedTest + @EnumSource(Rate::class) + fun `any rate length should be at least 3`(rate: Rate) { + assertThat(rate.name).isNotNull() + // We know all rates 3-digit length except UNKNOWN + assertThat(rate.name.length).isAtLeast(3) + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/data/mapper/CurrencyDomainMapperTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/mapper/CurrencyDomainMapperTest.kt new file mode 100644 index 0000000..47d167d --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/mapper/CurrencyDomainMapperTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.mapper + +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.raw.CurrencyResponseRaw +import io.github.nuhkoca.libbra.data.shared.rule.CoroutinesTestRule +import io.github.nuhkoca.libbra.shared.ext.runBlockingTest +import io.github.nuhkoca.libbra.util.mapper.Mapper +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import io.github.nuhkoca.libbra.data.model.domain.Rate as DomainRate + +/** + * A test class for [CurrencyDomainMapper] + */ +@RunWith(MockitoJUnitRunner::class) +@SmallTest +class CurrencyDomainMapperTest { + + /* + ------------ + | Rules | + ------------ + */ + @ExperimentalCoroutinesApi + @get:Rule + val coroutinesTestRule = CoroutinesTestRule() + + /* + ----------------------- + | Private members | + ----------------------- + */ + private lateinit var mapper: Mapper + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + mapper = CurrencyDomainMapper(coroutinesTestRule.testDispatcherProvider) + } + + @Test + @ExperimentalCoroutinesApi + fun `mapper should map raw data to domain type properly`() = + coroutinesTestRule.runBlockingTest { + // Given + val currencyResponseRaw = CurrencyResponseRaw( + baseCurrency = "EUR", + rates = mapOf(Rate.CZK to 1.2f, Rate.AUD to 3.5f) + ) + + // When + val response = mapper.map(currencyResponseRaw) + + // Then + assertThat(response).isNotNull() + assertThat(response.rates).isNotNull() + assertThat(response.rates).hasSize(2) + assertThat(response.rates).containsExactlyElementsIn( + listOf(DomainRate(Rate.CZK, 1.2f), DomainRate(Rate.AUD, 3.5f)) + ).inOrder() + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/data/serializers/RateSerializerTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/serializers/RateSerializerTest.kt new file mode 100644 index 0000000..19ebf9d --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/serializers/RateSerializerTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.serializers + +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import io.github.nuhkoca.libbra.data.enums.Rate +import kotlinx.serialization.UnstableDefault +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonConfiguration +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +/** + * A test class for [RateSerializer] + */ +@RunWith(MockitoJUnitRunner::class) +@SmallTest +class RateSerializerTest { + + /* + ----------------------- + | Private members | + ----------------------- + */ + private val serializer = RateSerializer + + @Test + @UnstableDefault + fun `serializer should serialize the given enum object properly`() { + // Given + val rate = Rate.BRL + + // When + val rateAsString = Json(JsonConfiguration(unquotedPrint = true)) + .stringify(serializer, rate) + + // Then + assertThat(rateAsString).isNotEmpty() + assertThat(rateAsString).isEqualTo("BRL") + } + + @Test + @UnstableDefault + fun `serializer should parse the given string to corresponding enum type`() { + // Given + val rateAsString = "CZK" + + // When + val rate = Json(JsonConfiguration(isLenient = true)).parse(serializer, rateAsString) + + // Then + assertThat(rate).isNotNull() + assertThat(rate).isInstanceOf(Rate::class.java) + assertThat(rate).isEqualTo(Rate.CZK) + } + + @Test + @UnstableDefault + fun `serializer should map undefined item to UNKNOWN type`() { + // Given + val rateAsString = "UND" + + // When + val rate = Json(JsonConfiguration(isLenient = true)).parse(serializer, rateAsString) + + // Then + assertThat(rate).isNotNull() + assertThat(rate).isInstanceOf(Rate::class.java) + assertThat(rate).isEqualTo(Rate.UNKNOWN) + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/data/service/CurrencyServiceTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/service/CurrencyServiceTest.kt new file mode 100644 index 0000000..7be5a68 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/service/CurrencyServiceTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.service + +import androidx.test.filters.LargeTest +import com.google.common.truth.Truth.assertThat +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.raw.CurrencyResponseRaw +import io.github.nuhkoca.libbra.data.verifier.RevolutHostnameVerifier +import io.github.nuhkoca.libbra.shared.MEDIA_TYPE_DEFAULT +import io.github.nuhkoca.libbra.shared.dispatcher.ErrorDispatcher +import io.github.nuhkoca.libbra.shared.dispatcher.SuccessDispatcher +import io.github.nuhkoca.libbra.shared.dispatcher.TimeoutDispatcher +import io.github.nuhkoca.libbra.util.ext.ErrorInterceptor +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.UnstableDefault +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import retrofit2.Retrofit +import retrofit2.create +import java.util.concurrent.TimeUnit + +/** + * A test class for [CurrencyService] + */ +@RunWith(MockitoJUnitRunner::class) +@LargeTest +class CurrencyServiceTest { + + /* + ----------------------- + | Private members | + ----------------------- + */ + private val mockWebServer = MockWebServer() + private lateinit var currencyService: CurrencyService + + @Before + @UnstableDefault + fun setUp() { + mockWebServer.start() + + val client = OkHttpClient.Builder().apply { + hostnameVerifier(RevolutHostnameVerifier) + connectTimeout(TIMEOUT_IN_MS, TimeUnit.MILLISECONDS) + readTimeout(TIMEOUT_IN_MS, TimeUnit.MILLISECONDS) + writeTimeout(TIMEOUT_IN_MS, TimeUnit.MILLISECONDS) + addInterceptor(HttpLoggingInterceptor()) + addInterceptor(ErrorInterceptor()) + }.build() + + val retrofit = Retrofit.Builder().apply { + client(client) + baseUrl(mockWebServer.url("/").toString()) + addConverterFactory(Json.asConverterFactory(MEDIA_TYPE_DEFAULT.toMediaType())) + }.build() + + currencyService = retrofit.create() + + mockWebServer.dispatcher = SuccessDispatcher(CURRENCY_SUCCESS_RESPONSE_FILE_NAME) + } + + /* + * We are unable to manipulate Retrofit's dispatcher so that we have to use runBlocking + */ + @Test + @ExperimentalCoroutinesApi + fun `list of currencies should be fetched`() = runBlocking { + // Given + val base = Rate.EUR + + // When + val response = currencyService.getCurrencyList(base) + + // Then + assertThat(response).isNotNull() + assertThat(response.baseCurrency).isEqualTo(base.name) + assertThat(response.rates).isNotNull() + assertThat(response.rates).hasSize(31) + } + + /* + * We are unable to manipulate Retrofit's dispatcher so that we have to use runBlocking + */ + @Test + fun `currency service should throw an error`() = runBlocking { + mockWebServer.dispatcher = ErrorDispatcher + + // Given + val base = Rate.AUD + + var response: CurrencyResponseRaw? = null + + // When + try { + response = currencyService.getCurrencyList(base) + } catch (e: Exception) { + assertThat(e.message).contains("Unexpected JSON token at") + } + + // Then + assertThat(response).isNull() + assertThat(response?.rates).isNull() + } + + /* + * We are unable to manipulate Retrofit's dispatcher so that we have to use runBlocking + */ + @Test + fun `request should be timed out`() = runBlocking { + mockWebServer.dispatcher = TimeoutDispatcher + + // Given + val base = Rate.DKK + + var response: CurrencyResponseRaw? = null + + // When + try { + response = currencyService.getCurrencyList(base) + } catch (e: Exception) { + assertThat(e.message).isEqualTo("timeout") + } + + // Then + assertThat(response).isNull() + assertThat(response?.rates).isNull() + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + private companion object { + // For test purpose + private const val TIMEOUT_IN_MS = 1000L + private const val CURRENCY_SUCCESS_RESPONSE_FILE_NAME = "currency_success_response.json" + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/data/verifier/RevolutHostnameVerifierTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/verifier/RevolutHostnameVerifierTest.kt new file mode 100644 index 0000000..ae31f04 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/data/verifier/RevolutHostnameVerifierTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.verifier + +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import io.github.nuhkoca.libbra.shared.BASE_URL +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +/** + * A test class for [RevolutHostnameVerifier] + */ +@RunWith(MockitoJUnitRunner::class) +@SmallTest +class RevolutHostnameVerifierTest { + + /* + ----------------------- + | Private members | + ----------------------- + */ + private val revolutHostNameVerifierTest: RevolutHostnameVerifier + get() = RevolutHostnameVerifier + + @Test + fun `verifier should verify the base url`() { + // Given + val hostname = BASE_URL + + // When + val result = revolutHostNameVerifierTest.verify(hostname, null) + + // Then + assertThat(result).isTrue() + } + + @Test + fun `verifier should not allow any network traffic except base url`() { + // Given + val hostname = null + + // When + val result = revolutHostNameVerifierTest.verify(hostname, null) + + // Then + assertThat(result).isFalse() + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/mapper/CurrencyViewItemMapperTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/mapper/CurrencyViewItemMapperTest.kt new file mode 100644 index 0000000..f74d43c --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/mapper/CurrencyViewItemMapperTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.domain.mapper + +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.view.CurrencyResponseViewItem +import io.github.nuhkoca.libbra.data.model.view.RateViewItem +import io.github.nuhkoca.libbra.data.shared.rule.CoroutinesTestRule +import io.github.nuhkoca.libbra.shared.ext.runBlockingTest +import io.github.nuhkoca.libbra.util.mapper.Mapper +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import io.github.nuhkoca.libbra.data.model.domain.Rate as DomainRate + +/** + * A test class for [CurrencyViewItemMapper] + */ +@RunWith(MockitoJUnitRunner::class) +@SmallTest +class CurrencyViewItemMapperTest { + + /* + ------------ + | Rules | + ------------ + */ + @ExperimentalCoroutinesApi + @get:Rule + val coroutinesTestRule = CoroutinesTestRule() + + /* + ----------------------- + | Private members | + ----------------------- + */ + private lateinit var mapper: Mapper + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + mapper = CurrencyViewItemMapper(coroutinesTestRule.testDispatcherProvider) + } + + @Test + @ExperimentalCoroutinesApi + fun `mapper should map domain data to view item type properly`() = + coroutinesTestRule.runBlockingTest { + // Given + val currencyResponse = CurrencyResponse( + baseCurrency = "EUR", + rates = listOf(DomainRate(Rate.BGN, 3.9f), DomainRate(Rate.CHF, 2.1f)) + ) + + // When + val response = mapper.map(currencyResponse) + + // Then + assertThat(response).isNotNull() + assertThat(response.rates).isNotNull() + assertThat(response.rates).hasSize(3) + assertThat(response.rates).containsExactlyElementsIn( + listOf( + RateViewItem( + id = 0, + abbreviation = "EUR", + longName = "Euro", + amount = 1.0f, + icon = 2131230839 + ), + RateViewItem( + id = 1, + abbreviation = "BGN", + longName = "Bulgarian Lev", + amount = 3.9f, + icon = 2131230829 + ), + RateViewItem( + id = 2, + abbreviation = "CHF", + longName = "Swiss Franc", + amount = 2.1f, + icon = 2131230833 + ) + ) + ).inOrder() + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/repository/CurrencyRepositoryTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/repository/CurrencyRepositoryTest.kt new file mode 100644 index 0000000..3aefa83 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/repository/CurrencyRepositoryTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.domain.repository + +import BaseTestClass +import androidx.test.filters.MediumTest +import com.google.common.truth.Truth +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.datasource.DataSource +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.shared.rule.CoroutinesTestRule +import io.github.nuhkoca.libbra.shared.assertion.test +import io.github.nuhkoca.libbra.shared.ext.runBlockingTest +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +/** + * A test class for [CurrencyRepository] + */ +@RunWith(MockitoJUnitRunner::class) +@MediumTest +class CurrencyRepositoryTest : BaseTestClass() { + + /* + ------------ + | Rules | + ------------ + */ + @ExperimentalCoroutinesApi + @get:Rule + val coroutinesTestRule = CoroutinesTestRule() + + /* + ------------- + | Mocks | + ------------- + */ + @MockK + private lateinit var dataSource: DataSource + + @RelaxedMockK + private lateinit var currencyResponse: CurrencyResponse + + /* + ----------------------- + | Private members | + ----------------------- + */ + private lateinit var repository: Repository + private val currencySlot = slot() + + override fun setUp() { + super.setUp() + + every { dataSource.getCurrencyList(capture(currencySlot)) } answers { + flowOf(Result.Success(currencyResponse)) + } + + every { currencyResponse.baseCurrency } returns "THB" + + repository = CurrencyRepository(dataSource) + } + + @Test + @ExperimentalCoroutinesApi + fun `repository should return data`() = coroutinesTestRule.runBlockingTest { + // Given + val base = Rate.THB + + // When + val flow = repository.getCurrencyList(base) + + // Then + flow.test { + expectItem().run { + Truth.assertThat(this).isNotNull() + Truth.assertThat(this).isInstanceOf(Result.Success::class.java) + this as Result.Success + Truth.assertThat(data.baseCurrency).isEqualTo(currencySlot.captured.name) + Truth.assertThat(data.rates).isNotNull() + } + expectComplete() + } + + verify(exactly = 1) { dataSource.getCurrencyList(any()) } + confirmVerified(dataSource) + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/usecase/CurrencyUseCaseTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/usecase/CurrencyUseCaseTest.kt new file mode 100644 index 0000000..d77e65f --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/domain/usecase/CurrencyUseCaseTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.domain.usecase + +import BaseTestClass +import androidx.test.filters.MediumTest +import com.google.common.truth.Truth +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.shared.rule.CoroutinesTestRule +import io.github.nuhkoca.libbra.domain.repository.Repository +import io.github.nuhkoca.libbra.shared.assertion.test +import io.github.nuhkoca.libbra.shared.ext.runBlockingTest +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +@MediumTest +class CurrencyUseCaseTest : BaseTestClass() { + + /* + ------------ + | Rules | + ------------ + */ + @ExperimentalCoroutinesApi + @get:Rule + val coroutinesTestRule = CoroutinesTestRule() + + /* + ------------- + | Mocks | + ------------- + */ + @MockK + private lateinit var repository: Repository + + @RelaxedMockK + private lateinit var currencyResponse: CurrencyResponse + + /* + ----------------------- + | Private members | + ----------------------- + */ + private lateinit var useCase: UseCase.FlowUseCase + private val currencySlot = slot() + + override fun setUp() { + super.setUp() + + every { repository.getCurrencyList(capture(currencySlot)) } answers { + flowOf(Result.Success(currencyResponse)) + } + + every { currencyResponse.baseCurrency } returns "HRK" + + useCase = CurrencyUseCase(repository) + } + + @Test + @ExperimentalCoroutinesApi + fun `use case should return data properly`() = coroutinesTestRule.runBlockingTest { + // Given + val base = Rate.HRK + + // Then + val flow = useCase.execute(CurrencyParams(base)) + + // Then + flow.test { + expectItem().run { + Truth.assertThat(this).isNotNull() + Truth.assertThat(this).isInstanceOf(Result.Success::class.java) + this as Result.Success + Truth.assertThat(data.baseCurrency).isEqualTo(base.name) + Truth.assertThat(data.rates).isNotNull() + } + expectComplete() + } + + verify(exactly = 1) { repository.getCurrencyList(any()) } + confirmVerified(repository) + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/MockConstants.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/MockConstants.kt new file mode 100644 index 0000000..a1bc89c --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/MockConstants.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.shared + +const val BASE_URL = "https://hiring.revolut.codes/" +const val ENDPOINT_PREFIX = "latest" +const val RESPONSE_DIR_PREFIX = "response" +const val MEDIA_TYPE_DEFAULT = "application/json" diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/assertion/FlowAssert.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/assertion/FlowAssert.kt new file mode 100644 index 0000000..46eabc9 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/assertion/FlowAssert.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.shared.assertion + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.channels.receiveOrNull +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +/** + * A custom assertion for [Flow]. + */ +// Credit: https://github.com/cashapp/sqldelight/blob/master/extensions/coroutines-extensions/src/commonTest/kotlin/com/squareup/sqldelight/runtime/coroutines/FlowAssert.kt +@ExperimentalCoroutinesApi +suspend fun Flow.test(timeoutMs: Long = 1000L, validate: suspend FlowAssert.() -> Unit) { + coroutineScope { + val events = Channel>(UNLIMITED) + val collectJob = launch { + val terminalEvent = try { + collect { item -> + events.send( + Event.Item( + item + ) + ) + } + Event.Complete + } catch (_: CancellationException) { + null + } catch (t: Throwable) { + Event.Error(t) + } + if (terminalEvent != null) { + events.send(terminalEvent) + } + events.close() + } + val flowAssert = FlowAssert( + events, + collectJob, + timeoutMs + ) + val ensureConsumed = try { + flowAssert.validate() + true + } catch (e: CancellationException) { + if (e !== ignoreRemainingEventsException) { + throw e + } + false + } + if (ensureConsumed) { + flowAssert.expectNoMoreEvents() + } + } +} + +private val ignoreRemainingEventsException = CancellationException("Ignore remaining events") + +internal sealed class Event { + object Complete : Event() + data class Error(val throwable: Throwable) : Event() + data class Item(val item: T) : Event() +} + +class FlowAssert internal constructor( + private val events: Channel>, + private val collectJob: Job, + private val timeoutMs: Long +) { + private suspend fun withTimeout(body: suspend () -> T): T { + return if (timeoutMs == 0L) { + body() + } else { + withTimeout(timeoutMs) { + body() + } + } + } + + fun cancel() { + collectJob.cancel() + } + + fun cancelAndIgnoreRemainingEvents(): Nothing { + cancel() + throw ignoreRemainingEventsException + } + + fun expectNoEvents() { + val event = events.poll() + if (event != null) { + throw AssertionError("Expected no events but found $event") + } + } + + @ExperimentalCoroutinesApi + suspend fun expectNoMoreEvents() { + val event = withTimeout { + events.receiveOrNull() + } + if (event != null) { + throw AssertionError("Expected no more events but found $event") + } + } + + suspend fun expectItem(): T { + val event = withTimeout { + events.receive() + } + if (event !is Event.Item) { + throw AssertionError("Expected item but was $event") + } + return event.item + } + + suspend fun expectComplete() { + val event = withTimeout { + events.receive() + } + if (event != Event.Complete) { + throw AssertionError("Expected complete but was $event") + } + } + + suspend fun expectError(): Throwable { + val event = withTimeout { + events.receive() + } + if (event !is Event.Error) { + throw AssertionError("Expected error but was $event") + } + return event.throwable + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/base/BaseTestClass.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/base/BaseTestClass.kt new file mode 100644 index 0000000..8a85984 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/base/BaseTestClass.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import androidx.annotation.CallSuper +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before + +/** + * A base class for all test classes. If child class wants to override any of stated + * methods they must call super first. + */ +open class BaseTestClass { + + /** + * An open method that initializes mock-ups before starting a test. + */ + @CallSuper + @Before + open fun setUp() { + MockKAnnotations.init(this) + } + + /** + * An open method that unmocks & clears all mock-ups in order to avoid any leak. + */ + @CallSuper + @After + open fun tearDown() { + unmockkAll() + clearAllMocks() + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/ErrorDispatcher.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/ErrorDispatcher.kt new file mode 100644 index 0000000..1a41679 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/ErrorDispatcher.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.shared.dispatcher + +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import javax.net.ssl.HttpsURLConnection.HTTP_NOT_FOUND + +/** + * A custom [MockWebServer] [Dispatcher] implementation for error case. This will only return + * [HTTP_NOT_FOUND] and will cause a crash. Therefore calls should be wrapper with + * try-catch or so on. + */ +object ErrorDispatcher : Dispatcher() { + + /** + * @return [MockResponse] with [HTTP_NOT_FOUND] code. + */ + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse().setResponseCode(HTTP_NOT_FOUND) + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/SuccessDispatcher.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/SuccessDispatcher.kt new file mode 100644 index 0000000..8c380e9 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/SuccessDispatcher.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.shared.dispatcher + +import io.github.nuhkoca.libbra.shared.ENDPOINT_PREFIX +import io.github.nuhkoca.libbra.shared.RESPONSE_DIR_PREFIX +import io.github.nuhkoca.libbra.shared.ext.asset +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import javax.net.ssl.HttpsURLConnection.HTTP_NOT_FOUND +import javax.net.ssl.HttpsURLConnection.HTTP_OK + +/** + * A custom [MockWebServer] [Dispatcher] implementation for success case. This will return + * [HTTP_OK] if path contains the endpoint and file name is valid. + */ +class SuccessDispatcher(private val fileName: String) : Dispatcher() { + + /** + * @return [MockResponse] with [HTTP_OK] if path contains the endpoint and file name is valid + * otherwise [HTTP_NOT_FOUND] + */ + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path + + return if (path.toString().contains(ENDPOINT_PREFIX)) { + val response = asset("$RESPONSE_DIR_PREFIX/$fileName") + + if (response.isNullOrEmpty()) MockResponse().setResponseCode(HTTP_NOT_FOUND) + + // response is not null in any case + MockResponse().setResponseCode(HTTP_OK).setBody(response!!) + } else { + MockResponse().setResponseCode(HTTP_NOT_FOUND) + } + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/TimeoutDispatcher.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/TimeoutDispatcher.kt new file mode 100644 index 0000000..3069ac4 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/dispatcher/TimeoutDispatcher.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.shared.dispatcher + +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.SocketPolicy.NO_RESPONSE +import java.util.concurrent.TimeUnit + +/** + * A custom [MockWebServer] [Dispatcher] implementation for timeout case. This will delay response + * remaining faithful to [OkHttpClient] settings for the test environment. + */ +object TimeoutDispatcher : Dispatcher() { + + private const val BYTES_PER_PERIOD = 1024L + private const val PERIOD = 2L + + /** + * @return [MockResponse] with throttled body and [NO_RESPONSE] policy. + */ + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse() + .setSocketPolicy(NO_RESPONSE) + .throttleBody(BYTES_PER_PERIOD, PERIOD, TimeUnit.SECONDS) + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/ext/FileExt.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/ext/FileExt.kt new file mode 100644 index 0000000..364cbb3 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/ext/FileExt.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.shared.ext + +import io.github.nuhkoca.libbra.shared.reader.AssetReader +import java.io.IOException + +/** + * Reads given file from resources directory. + * + * @param fileName The file name + * + * @return content as [String] + * + * @throws IOException if file is not found + */ +@Throws(IOException::class) +fun asset(fileName: String) = AssetReader.getJsonDataFromAsset(fileName) diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/ext/TestRuleExt.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/ext/TestRuleExt.kt new file mode 100644 index 0000000..c18bdb9 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/ext/TestRuleExt.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.shared.ext + +import io.github.nuhkoca.libbra.data.shared.rule.CoroutinesTestRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest + +/** + * Runs test with the current test dispatcher of [CoroutinesTestRule]. + * + * @param block The code block should be run + */ +@ExperimentalCoroutinesApi +fun CoroutinesTestRule.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) { + testDispatcher.runBlockingTest(block) +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/reader/AssetReader.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/reader/AssetReader.kt new file mode 100644 index 0000000..af411f8 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/reader/AssetReader.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.shared.reader + +import java.io.IOException + +/** + * A helper class to read specific file from resources directory. + */ +object AssetReader { + + /** + * Reads and returns content as [String] + * + * @param fileName the file name + * + * @return [String] + * + * @throws IOException + */ + @Throws(IOException::class) + @JvmStatic + fun getJsonDataFromAsset(fileName: String): String? { + val jsonString: String? + try { + jsonString = javaClass.classLoader?.getResourceAsStream(fileName) + ?.bufferedReader() + ?.use { it.readText() } + } catch (e: IOException) { + e.printStackTrace() + return null + } + return jsonString + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/rule/CoroutinesTestRule.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/rule/CoroutinesTestRule.kt new file mode 100644 index 0000000..e88ed7c --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/shared/rule/CoroutinesTestRule.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.data.shared.rule + +import io.github.nuhkoca.libbra.util.coroutines.DispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * A test rule for [Coroutines]. All suspendend methods should be run under this test rule to + * execute functions with a test thread. Otherwise test will fail. + * + * @param testDispatcher The [TestCoroutineDispatcher] + */ +@ExperimentalCoroutinesApi +class CoroutinesTestRule constructor( + val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() +) : TestWatcher() { + + // Test dispatchers to use in tests, this manipulates main thread + internal val testDispatcherProvider = object : DispatcherProvider { + override val main: CoroutineDispatcher + get() = testDispatcher + override val default: CoroutineDispatcher + get() = testDispatcher + override val io: CoroutineDispatcher + get() = testDispatcher + override val unconfined: CoroutineDispatcher + get() = testDispatcher + } + + /** + * Invoked when a test is about to start + */ + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + /** + * Invoked when a test method finishes (whether passing or failing) + */ + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyViewModelTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyViewModelTest.kt new file mode 100644 index 0000000..a16f7e6 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/ui/currency/CurrencyViewModelTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.ui.currency + +import BaseTestClass +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import androidx.test.filters.LargeTest +import com.google.common.truth.Truth.assertThat +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.failure.Failure +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.model.view.CurrencyResponseViewItem +import io.github.nuhkoca.libbra.data.model.view.RateViewItem +import io.github.nuhkoca.libbra.data.shared.rule.CoroutinesTestRule +import io.github.nuhkoca.libbra.domain.usecase.CurrencyParams +import io.github.nuhkoca.libbra.domain.usecase.UseCase +import io.github.nuhkoca.libbra.util.mapper.Mapper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +@LargeTest +class CurrencyViewModelTest : BaseTestClass() { + + /* + ------------ + | Rules | + ------------ + */ + @ExperimentalCoroutinesApi + @get:Rule + val coroutinesTestRule = CoroutinesTestRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + /* + ------------- + | Mocks | + ------------- + */ + @MockK + private lateinit var useCase: UseCase.FlowUseCase + + @MockK + private lateinit var mapper: Mapper + + @RelaxedMockK + private lateinit var currencyResponse: CurrencyResponse + + @RelaxedMockK + private lateinit var observer: Observer + + /* + ------------------------- + | Private variables | + ------------------------- + */ + private lateinit var currencyViewModel: CurrencyViewModel + + @ExperimentalCoroutinesApi + override fun setUp() { + super.setUp() + + currencyViewModel = + CurrencyViewModel( + useCase, + mapper, + coroutinesTestRule.testDispatcherProvider + ) + } + + @Test + @ExperimentalCoroutinesApi + fun `currency view state should be filled after data has been fetched`() { + every { useCase.execute(any()) } answers { + flowOf(Result.Success(currencyResponse)) + } + + coEvery { mapper.map(any()) } returns CurrencyResponseViewItem( + baseCurrency = "EUR", + rates = listOf( + RateViewItem( + id = 0, + abbreviation = "EUR", + longName = "Euro", + amount = 1f, + icon = 2131230839 + ), + RateViewItem( + id = 1, + abbreviation = "GBP", + longName = "Pound", + amount = 0.8f, + icon = 2131230840 + ) + ) + ) + + currencyViewModel.currencyLiveData.observeForever(observer) + + currencyViewModel.setBaseCurrency(Rate.EUR) + + val value = currencyViewModel.currencyLiveData.value + + assertThat(value?.isLoading).isFalse() + assertThat(value?.hasError).isFalse() + assertThat(value?.errorMessage).isNull() + assertThat(value?.data).isNotNull() + assertThat(value?.data?.baseCurrency).isAtLeast("EUR") + assertThat(value?.data?.rates).hasSize(2) + + verify { useCase.execute(any()) } + coVerify { mapper.map(any()) } + + confirmVerified(useCase) + confirmVerified(mapper) + } + + @Test + @ExperimentalCoroutinesApi + fun `currency view state should be filled after the exception occurred`() { + every { useCase.execute(any()) } answers { + flowOf(Result.Error(Failure.ServerFailure("Couldn't connect the server."))) + } + + currencyViewModel.currencyLiveData.observeForever(observer) + + currencyViewModel.setBaseCurrency(Rate.EUR) + + val value = currencyViewModel.currencyLiveData.value + + assertThat(value?.isLoading).isFalse() + assertThat(value?.hasError).isTrue() + assertThat(value?.errorMessage).isNotNull() + assertThat(value?.errorMessage).isEqualTo("Couldn't connect the server.") + assertThat(value?.data).isNull() + assertThat(value?.data?.rates).isNull() + + verify { useCase.execute(any()) } + + confirmVerified(useCase) + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultAsyncManagerTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultAsyncManagerTest.kt new file mode 100644 index 0000000..10ad5c8 --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultAsyncManagerTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.coroutines + +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import io.github.nuhkoca.libbra.data.Result +import io.github.nuhkoca.libbra.data.enums.Rate +import io.github.nuhkoca.libbra.data.failure.Failure +import io.github.nuhkoca.libbra.data.model.domain.CurrencyResponse +import io.github.nuhkoca.libbra.data.shared.rule.CoroutinesTestRule +import io.github.nuhkoca.libbra.shared.assertion.test +import io.github.nuhkoca.libbra.shared.ext.runBlockingTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import io.github.nuhkoca.libbra.data.model.domain.Rate as DomainRate + +/** + * A test class for [DefaultAsyncManager] + */ +@RunWith(MockitoJUnitRunner::class) +@SmallTest +class DefaultAsyncManagerTest { + + /* + ------------ + | Rules | + ------------ + */ + @ExperimentalCoroutinesApi + @get:Rule + val coroutinesTestRule = CoroutinesTestRule() + + /* + ----------------------- + | Private members | + ----------------------- + */ + private lateinit var asyncManager: AsyncManager + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + asyncManager = DefaultAsyncManager(coroutinesTestRule.testDispatcherProvider) + } + + @Test + @ExperimentalCoroutinesApi + fun `async manager should return data successfully`() = coroutinesTestRule.runBlockingTest { + // Given + val mockedResponse = CurrencyResponse( + baseCurrency = "EUR", + rates = listOf(DomainRate(Rate.GBP, 1.1f), DomainRate(Rate.RUB, 4.1f)) + ) + + // When + val flow = asyncManager.handleAsyncWithTryCatch { mockedResponse } + + // Then + flow.test { + expectItem().run { + assertThat(this).isInstanceOf(Result.Success::class.java) + this as Result.Success + assertThat(data).isNotNull() + assertThat(data.baseCurrency).isEqualTo("EUR") + assertThat(data.rates).hasSize(2) + assertThat(data.rates).containsExactly( + DomainRate(Rate.GBP, 1.1f), DomainRate(Rate.RUB, 4.1f) + ).inOrder() + } + cancel() + expectNoMoreEvents() + } + } + + @Test + @ExperimentalCoroutinesApi + fun `async manager should handle exception properly`() = coroutinesTestRule.runBlockingTest { + // Given + val exception = Failure.ServerFailure("Couldn't connect the server.") + + // When + val flow = asyncManager.handleAsyncWithTryCatch { throw exception } + + // Then + flow.test { + expectItem().run { + assertThat(this).isInstanceOf(Result.Error::class.java) + this as Result.Error + assertThat(failure).isInstanceOf(Failure.ServerFailure::class.java) + assertThat(failure.message).isEqualTo("Couldn't connect the server.") + } + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/app/src/test/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultDispatcherProviderTest.kt b/app/src/test/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultDispatcherProviderTest.kt new file mode 100644 index 0000000..6282c5c --- /dev/null +++ b/app/src/test/kotlin/io/github/nuhkoca/libbra/util/coroutines/DefaultDispatcherProviderTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.libbra.util.coroutines + +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineDispatcher +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.ArgumentsProvider +import org.junit.jupiter.params.provider.ArgumentsSource +import java.util.stream.Stream + +/** + * A parameterized test for [DefaultDispatcherProvider] + */ +@SmallTest +class DefaultDispatcherProviderTest { + + /** + * A parameterized test function that iterates and performs test on all [CoroutineDispatcher]. + * + * @param dispatcher represents any [CoroutineDispatcher] + */ + @ParameterizedTest + @ArgumentsSource(CustomArgumentsProvider::class) + fun `any dispatcher should not be null`(dispatcher: CoroutineDispatcher) { + assertThat(dispatcher).isNotNull() + } +} + +/** + * A custom [ArgumentsProvider] to have any [CoroutineDispatcher] parameterized in tests. + */ +private class CustomArgumentsProvider : ArgumentsProvider { + + private val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider() + + /** + * Provides a stream of arguments to be passed to a [ParameterizedTest] method. + * + * @param extensionContext the current extension context + * + * @return a stream of arguments + */ + @Throws(Exception::class) + override fun provideArguments(extensionContext: ExtensionContext?): Stream? { + return Stream.of( + dispatcherProvider.main, + dispatcherProvider.io, + dispatcherProvider.default, + dispatcherProvider.unconfined + ).map { dispatcher -> Arguments.of(dispatcher) } + } +} diff --git a/app/src/test/resources/response/currency_success_response.json b/app/src/test/resources/response/currency_success_response.json new file mode 100644 index 0000000..aa13f9f --- /dev/null +++ b/app/src/test/resources/response/currency_success_response.json @@ -0,0 +1,36 @@ +{ + "baseCurrency": "EUR", + "rates": { + "AUD": 1.603, + "BGN": 1.972, + "BRL": 4.252, + "CAD": 1.519, + "CHF": 1.146, + "CNY": 7.673, + "CZK": 25.825, + "DKK": 7.553, + "GBP": 0.888, + "HKD": 8.891, + "HRK": 7.534, + "HUF": 320.098, + "IDR": 16202.383, + "ILS": 4.099, + "INR": 81.673, + "ISK": 136.116, + "JPY": 127.194, + "KRW": 1288.335, + "MXN": 22.147, + "MYR": 4.616, + "NOK": 9.852, + "NZD": 1.651, + "PHP": 60.253, + "PLN": 4.41, + "RON": 4.793, + "RUB": 75.68, + "SEK": 10.55, + "SGD": 1.543, + "THB": 35.601, + "USD": 1.136, + "ZAR": 16.14 + } +} \ No newline at end of file diff --git a/art/dark_mode.jpg b/art/dark_mode.jpg new file mode 100644 index 0000000..8da093d Binary files /dev/null and b/art/dark_mode.jpg differ diff --git a/art/design_pattern.png b/art/design_pattern.png new file mode 100644 index 0000000..2376608 Binary files /dev/null and b/art/design_pattern.png differ diff --git a/art/light_mode.jpg b/art/light_mode.jpg new file mode 100644 index 0000000..710f83b Binary files /dev/null and b/art/light_mode.jpg differ diff --git a/art/preview.png b/art/preview.png new file mode 100644 index 0000000..8833146 Binary files /dev/null and b/art/preview.png differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..d3f9069 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import extensions.applyDefaults +import plugins.BuildPlugins +import tasks.BuildTasks + +buildscript { + repositories { + google() + mavenCentral() + + // Make this a caching provider + jcenter() + } +} + +plugins.apply(BuildPlugins.DETEKT) +plugins.apply(BuildPlugins.UPDATE_DEPENDENCIES) +plugins.apply(BuildPlugins.KTLINT) +plugins.apply(BuildPlugins.GIT_HOOKS) + +allprojects { + repositories.applyDefaults() + + plugins.apply(BuildPlugins.DETEKT) + plugins.apply(BuildPlugins.KTLINT) + plugins.apply(BuildPlugins.SPOTLESS) +} + +subprojects { + plugins.apply(BuildTasks.COMMON_TASKS) + + apply { + from("$rootDir/versions.gradle.kts") + } +} + +tasks.registering(Delete::class) { + delete(rootProject.buildDir) +} diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore new file mode 100644 index 0000000..ca730c4 --- /dev/null +++ b/buildSrc/.gitignore @@ -0,0 +1,2 @@ +/build +.gradle diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..a30be40 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + `kotlin-dsl` + `kotlin-dsl-precompiled-script-plugins` + `java-gradle-plugin` +} + +repositories { + google() + mavenCentral() + maven("https://plugins.gradle.org/m2/") + + // Make this a caching provider + jcenter() +} + +kotlinDslPluginOptions { + experimentalWarning.set(false) +} + +object PluginVersions { + const val gradle_plugin = "3.6.2" + const val kotlin_gradle_plugin = "1.3.71" + const val gradle_version_plugin = "0.28.0" + const val detekt = "1.7.0" + const val ktlint = "9.2.1" + const val spotless = "3.27.2" + const val junit5 = "1.6.0.0" +} + +dependencies { + implementation("com.android.tools.build:gradle:${PluginVersions.gradle_plugin}") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${PluginVersions.kotlin_gradle_plugin}") + implementation("org.jetbrains.kotlin:kotlin-serialization:${PluginVersions.kotlin_gradle_plugin}") + implementation("com.github.ben-manes:gradle-versions-plugin:${PluginVersions.gradle_version_plugin}") + implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:${PluginVersions.detekt}") + implementation("org.jlleitschuh.gradle:ktlint-gradle:${PluginVersions.ktlint}") + implementation("com.diffplug.spotless:spotless-plugin-gradle:${PluginVersions.spotless}") + implementation("de.mannodermaus.gradle.plugins:android-junit5:${PluginVersions.junit5}") +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000..26ff711 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +pluginManagement { + repositories { + maven("https://kotlin.bintray.com/kotlinx") + maven("https://jitpack.io") + maven("https://plugins.gradle.org/m2/") + } +} diff --git a/buildSrc/src/main/kotlin/BuildTypes.kt b/buildSrc/src/main/kotlin/BuildTypes.kt new file mode 100644 index 0000000..59b62d7 --- /dev/null +++ b/buildSrc/src/main/kotlin/BuildTypes.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import com.android.build.gradle.ProguardFiles.getDefaultProguardFile +import com.android.build.gradle.internal.dsl.BuildType +import extensions.gitSha +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.Project + +/** + * An object that includes build types + */ +private object InternalBuildType { + const val RELEASE = "release" + const val DEBUG = "debug" +} + +/** + * The common interface to create any build type + */ +@FunctionalInterface +private interface BuildTypeCreator { + + /** + * The val which includes name of the build type from [InternalBuildType] + */ + val name: String + + /** + * Creates the requested build type + * + * @param namedDomainObjectContainer The container to create the corresponding build type + * @param project The project + * + * @return The [BuildType] + */ + fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): BuildType +} + +/** + * A [BuildTypeCreator] implementation to create debug [BuildType] + */ +internal object Debug : BuildTypeCreator { + override val name = InternalBuildType.DEBUG + + override fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): BuildType { + return namedDomainObjectContainer.maybeCreate(name).apply { + versionNameSuffix = "-dev-${project.gitSha}" + isDebuggable = true + isMinifyEnabled = false + isUseProguard = false + } + } +} + +/** + * A [BuildTypeCreator] implementation to create release [BuildType] + */ +internal object Release : BuildTypeCreator { + override val name = InternalBuildType.RELEASE + + override fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): BuildType { + return namedDomainObjectContainer.maybeCreate(name).apply { + isMinifyEnabled = true + isDebuggable = false + isShrinkResources = true + isUseProguard = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt", project), + "proguard-rules.pro" + ) + } + } +} diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt new file mode 100644 index 0000000..60de1c1 --- /dev/null +++ b/buildSrc/src/main/kotlin/Config.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +object Config { + internal const val JSON_OUTPUT_FORMATTER = "json" + internal const val BUILD_STABLE_REGEX = "^[0-9,.v-]+(-r)?$" + internal const val KTLINT_COLOR_NAME = "RED" + internal const val SPOTLESS_INDENT_WITH_SPACES = 4 + + // testInstrumentationRunnerArguments + const val JUNIT5_KEY = "runnerBuilder" + const val JUNIT5_VALUE = "de.mannodermaus.junit5.AndroidJUnit5Builder" + + const val ORCHESTRATOR_KEY = "clearPackageData" + const val ORCHESTRATOR_VALUE = "true" +} diff --git a/buildSrc/src/main/kotlin/Modules.kt b/buildSrc/src/main/kotlin/Modules.kt new file mode 100644 index 0000000..3c9d22a --- /dev/null +++ b/buildSrc/src/main/kotlin/Modules.kt @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +object Modules { + const val lintRules = ":rules" +} diff --git a/buildSrc/src/main/kotlin/Plugins.kt b/buildSrc/src/main/kotlin/Plugins.kt new file mode 100644 index 0000000..04e5839 --- /dev/null +++ b/buildSrc/src/main/kotlin/Plugins.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +object Plugins { + // Core + const val androidApplication = "com.android.application" + const val kotlinAndroid = "android" + const val kotlinAndroidExtension = "android.extensions" + const val kotlinKapt = "kapt" + + // Other + const val kotlinSerialization = "kotlinx-serialization" + const val junit5 = "de.mannodermaus.android-junit5" +} diff --git a/buildSrc/src/main/kotlin/SigningConfigs.kt b/buildSrc/src/main/kotlin/SigningConfigs.kt new file mode 100644 index 0000000..827dd9f --- /dev/null +++ b/buildSrc/src/main/kotlin/SigningConfigs.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import com.android.build.gradle.internal.dsl.SigningConfig +import extensions.getProperty +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.Project +import java.io.File + +/** + * An object that includes signing configs + */ +private object InternalConfigType { + const val RELEASE = "release" +} + +/** + * The common interface to create any signing config + */ +@FunctionalInterface +private interface SigningConfigCreator { + + /** + * The val which includes name of the signing config from [InternalConfigType] + */ + val name: String + + /** + * Creates the requested signing config + * + * @param namedDomainObjectContainer The container to create the corresponding signing config + * @param project The project + * + * @return The [SigningConfig] + */ + fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): SigningConfig +} + +/** + * A [SigningConfigCreator] implementation to create release [SigningConfig] + */ +internal object ReleaseConfig : SigningConfigCreator { + override val name = InternalConfigType.RELEASE + + override fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): SigningConfig { + return namedDomainObjectContainer.create(name).apply { + storeFile = File("${project.rootDir}/${project.getProperty("signing.store.file")}") + storePassword = project.getProperty("signing.store.password") + keyAlias = project.getProperty("signing.key.alias") + keyPassword = project.getProperty("signing.key.password") + } + } +} diff --git a/buildSrc/src/main/kotlin/SourceSets.kt b/buildSrc/src/main/kotlin/SourceSets.kt new file mode 100644 index 0000000..34f2f0f --- /dev/null +++ b/buildSrc/src/main/kotlin/SourceSets.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import com.android.build.gradle.api.AndroidSourceSet +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.Project + +/** + * An object that includes source sets + */ +private object InternalSourceSet { + const val MAIN = "main" + const val TEST = "test" + const val ANDROID_TEST = "androidTest" +} + +/** + * The common interface to create any source set + */ +@FunctionalInterface +private interface SourceSetCreator { + + /** + * The val which includes name of the source set from [InternalSourceSet] + */ + val name: String + + /** + * Creates the requested source set + * + * @param namedDomainObjectContainer The container to create the corresponding source set + * @param project The project + * + * @return The [AndroidSourceSet] + */ + fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): AndroidSourceSet +} + +/** + * A [SourceSetCreator] implementation to create main Kotlin [AndroidSourceSet] + */ +internal object Main : SourceSetCreator { + override val name = InternalSourceSet.MAIN + + override fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): AndroidSourceSet { + return namedDomainObjectContainer.getByName(name).apply { + java.srcDir("src/main/kotlin") + } + } +} + +/** + * A [SourceSetCreator] implementation to create test Kotlin [AndroidSourceSet] + */ +internal object Test : SourceSetCreator { + override val name = InternalSourceSet.TEST + + override fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): AndroidSourceSet { + return namedDomainObjectContainer.getByName(name).apply { + java.srcDir("src/test/kotlin") + resources.srcDir("src/test/resources") + assets.srcDir("src/test/assets") + } + } +} + +/** + * A [SourceSetCreator] implementation to create android test Kotlin [AndroidSourceSet] + */ +internal object AndroidTest : SourceSetCreator { + override val name = InternalSourceSet.ANDROID_TEST + + override fun create( + namedDomainObjectContainer: NamedDomainObjectContainer, + project: Project + ): AndroidSourceSet { + return namedDomainObjectContainer.getByName(name).apply { + java.srcDir("src/androidTest/kotlin") + resources.srcDir("src/androidTest/resources") + assets.srcDir("src/androidTest/assets") + } + } +} diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt new file mode 100644 index 0000000..e3b1dab --- /dev/null +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +internal object Versions { + // Core + const val kotlin = "1.3.71" + const val kotlinx_serialization_runtime = "0.20.0" + const val coroutines = "1.3.4" + const val lint = "26.6.2" + + // UI + const val material = "1.1.0" + const val core = "1.2.0" + const val appcompat = "1.1.0" + const val activity_ktx = "1.1.0" + const val fragment_ktx = "1.2.4" + const val recyclerview = "1.2.0-alpha02" + const val constraint_layout = "1.1.3" + + // Navigation + const val android_navigation = "2.2.1" + + // Lifecycle + const val lifecycle = "2.2.0" + + // Dagger + const val dagger = "2.27" + + // Retrofit & OkHttp + const val retrofit = "2.8.1" + const val retrofit_serialization_adapter = "0.5.0" + const val okhttp = "4.4.1" + + // Other stuff + const val lottie = "3.4.0" + const val timber = "4.7.1" + const val coil = "0.9.5" + const val detekt = "1.7.0" + const val ktlint_internal = "0.36.0" + + // Test stuff + const val test_core = "1.2.0" + const val runner = "1.2.0" + const val rules = "1.2.0" + const val junit = "1.1.1" + const val truth_ext = "1.2.0" + const val espresso_core = "3.2.0" + const val mockK = "1.9.3" + const val arch_core = "2.1.0" + const val jupiter = "5.6.1" + const val android_test_runner = "1.2.0" + const val fragment = "1.2.4" + const val orchestrator = "1.2.0" +} diff --git a/buildSrc/src/main/kotlin/common/CommonDependencyHandler.kt b/buildSrc/src/main/kotlin/common/CommonDependencyHandler.kt new file mode 100644 index 0000000..b727d22 --- /dev/null +++ b/buildSrc/src/main/kotlin/common/CommonDependencyHandler.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("unused") + +package common + +import dependencies.Dependencies +import dependencies.TestDependencies +import org.gradle.api.artifacts.Dependency +import org.gradle.api.artifacts.dsl.DependencyHandler + +/** + * A common test dependency handler among all sub projects. This only should be called in case any + * sub project needs test and androidTest implementations. + */ +fun DependencyHandler.addTestDependencies() { + testImplementation(TestDependencies.test_core) + testImplementation(TestDependencies.runner) + testImplementation(TestDependencies.junit) + testImplementation(TestDependencies.rules) + testImplementation(TestDependencies.truth_ext) + testImplementation(TestDependencies.mockK) + testImplementation(TestDependencies.arch_core) + testImplementation(TestDependencies.coroutines_core) + testImplementation(TestDependencies.mock_web_server) + testImplementation(TestDependencies.serialization_runtime) + + androidTestImplementation(TestDependencies.test_core) + androidTestImplementation(TestDependencies.junit) + androidTestImplementation(TestDependencies.rules) + androidTestImplementation(TestDependencies.runner) + androidTestImplementation(TestDependencies.espresso_core) + androidTestImplementation(TestDependencies.idling_resource) + androidTestUtil(TestDependencies.orchestrator) + + debugImplementation(TestDependencies.fragment) +} + +/** + * A common JUnit5 test dependency handler among all sub projects. This only should be called in + * case any sub project needs test and androidTest implementations. + */ +fun DependencyHandler.addJUnit5TestDependencies() { + // (Required) Writing and executing Unit Tests on the JUnit Platform + testImplementation(TestDependencies.jupiter_api) + testRuntimeOnly(TestDependencies.jupiter_engine) + + // (Optional) If you need "Parameterized Tests" + testImplementation(TestDependencies.jupiter_params) + + // (Optional) If you also have JUnit 4-based tests + testRuntimeOnly(TestDependencies.vintage_engine) + androidTestRuntimeOnly(TestDependencies.android_test_runner) +} + +/** + * A Bill of Material implementation for OkHttp library group. + */ +fun DependencyHandler.addOkHttpBom() { + implementation(platform(Dependencies.Network.okhttp_bom)) + implementation(Dependencies.Network.okhttp) + implementation(Dependencies.Network.okhttp_logging) +} + +/* + * These extensions mimic the extensions that are generated on the fly by Gradle. + * They are used here to provide above dependency syntax that mimics Gradle Kotlin DSL + * syntax in module\build.gradle.kts files. + */ +private fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? = + add("implementation", dependencyNotation) + +private fun DependencyHandler.debugImplementation(dependencyNotation: Any): Dependency? = + add("debugImplementation", dependencyNotation) + +private fun DependencyHandler.testImplementation(dependencyNotation: Any): Dependency? = + add("testImplementation", dependencyNotation) + +private fun DependencyHandler.testRuntimeOnly(dependencyNotation: Any): Dependency? = + add("testRuntimeOnly", dependencyNotation) + +private fun DependencyHandler.androidTestImplementation(dependencyNotation: Any): Dependency? = + add("androidTestImplementation", dependencyNotation) + +private fun DependencyHandler.androidTestRuntimeOnly(dependencyNotation: Any): Dependency? = + add("androidTestRuntimeOnly", dependencyNotation) + +private fun DependencyHandler.androidTestUtil(dependencyNotation: Any): Dependency? = + add("androidTestUtil", dependencyNotation) diff --git a/buildSrc/src/main/kotlin/dependencies/Dependencies.kt b/buildSrc/src/main/kotlin/dependencies/Dependencies.kt new file mode 100644 index 0000000..2bb2418 --- /dev/null +++ b/buildSrc/src/main/kotlin/dependencies/Dependencies.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dependencies + +object Dependencies { + + object Core { + const val kotlin = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${Versions.kotlin}" + const val coroutines = + "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}" + } + + object Lint { + const val lint = "com.android.tools.lint:lint:${Versions.lint}" + const val api = "com.android.tools.lint:lint-api:${Versions.lint}" + const val checks = "com.android.tools.lint:lint-checks:${Versions.lint}" + const val tests = "com.android.tools.lint:lint-tests:${Versions.lint}" + } + + object UI { + const val material = "com.google.android.material:material:${Versions.material}" + const val core_ktx = "androidx.core:core-ktx:${Versions.core}" + const val appcompat = "androidx.appcompat:appcompat:${Versions.appcompat}" + const val recylerview = "androidx.recyclerview:recyclerview:${Versions.recyclerview}" + const val constraint_layout = + "androidx.constraintlayout:constraintlayout:${Versions.constraint_layout}" + const val fragment_ktx = "androidx.fragment:fragment-ktx:${Versions.fragment_ktx}" + const val activity_ktx = "androidx.activity:activity-ktx:${Versions.activity_ktx}" + } + + object Navigation { + const val nav_fragment_ktx = + "androidx.navigation:navigation-fragment-ktx:${Versions.android_navigation}" + const val nav_ui_ktx = + "androidx.navigation:navigation-ui-ktx:${Versions.android_navigation}" + } + + object Lifecycle { + const val lifecycle_extensions = + "androidx.lifecycle:lifecycle-extensions:${Versions.lifecycle}" + const val viewmodel_ktx = + "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.lifecycle}" + const val livedata_ktx = "androidx.lifecycle:lifecycle-livedata-ktx:${Versions.lifecycle}" + const val runtime_ktx = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.lifecycle}" + const val common_java = "androidx.lifecycle:lifecycle-common-java8:${Versions.lifecycle}" + } + + object Dagger { + const val dagger = "com.google.dagger:dagger:${Versions.dagger}" + const val compiler = "com.google.dagger:dagger-compiler:${Versions.dagger}" + } + + object Network { + const val retrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofit}" + const val retrofit_serialization_adapter = + "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:${Versions.retrofit_serialization_adapter}" + internal const val okhttp_bom = "com.squareup.okhttp3:okhttp-bom:${Versions.okhttp}" + internal const val okhttp = "com.squareup.okhttp3:okhttp" + internal const val okhttp_logging = "com.squareup.okhttp3:logging-interceptor" + } + + object Other { + const val lottie = "com.airbnb.android:lottie:${Versions.lottie}" + const val timber = "com.jakewharton.timber:timber:${Versions.timber}" + const val coil = "io.coil-kt:coil:${Versions.coil}" + } +} + +internal object TestDependencies { + // Core library + const val test_core = "androidx.test:core:${Versions.test_core}" + const val arch_core = "androidx.arch.core:core-testing:${Versions.arch_core}" + const val coroutines_core = + "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutines}" + const val serialization_runtime = + "org.jetbrains.kotlinx:kotlinx-serialization-runtime:${Versions.kotlinx_serialization_runtime}" + + // Fragment + const val fragment = "androidx.fragment:fragment-testing:${Versions.fragment}" + + // Orchestrator + const val orchestrator = "androidx.test:orchestrator:${Versions.orchestrator}" + + // AndroidJUnitRunner and JUnit Rules + const val runner = "androidx.test:runner:${Versions.runner}" + const val rules = "androidx.test:rules:${Versions.rules}" + + // Assertions + const val junit = "androidx.test.ext:junit:${Versions.junit}" + const val truth_ext = "androidx.test.ext:truth:${Versions.truth_ext}" + + // Espresso dependencies + const val espresso_core = "androidx.test.espresso:espresso-core:${Versions.espresso_core}" + const val idling_resource = + "androidx.test.espresso:espresso-idling-resource:${Versions.espresso_core}" + + // Mock + const val mockK = "io.mockk:mockk:${Versions.mockK}" + const val mock_web_server = "com.squareup.okhttp3:mockwebserver:${Versions.okhttp}" + + // JUnit5 + // (Required) Writing and executing Unit Tests on the JUnit Platform + const val jupiter_api = "org.junit.jupiter:junit-jupiter-api:${Versions.jupiter}" + const val jupiter_engine = "org.junit.jupiter:junit-jupiter-engine:${Versions.jupiter}" + + // (Optional) If you need "Parameterized Tests" + const val jupiter_params = "org.junit.jupiter:junit-jupiter-params:${Versions.jupiter}" + + // (Optional) If you also have JUnit 4-based tests + const val vintage_engine = "org.junit.vintage:junit-vintage-engine:${Versions.jupiter}" + const val android_test_runner = + "de.mannodermaus.junit5:android-test-runner:${Versions.android_test_runner}" +} diff --git a/buildSrc/src/main/kotlin/extensions/LintOptions.kt b/buildSrc/src/main/kotlin/extensions/LintOptions.kt new file mode 100644 index 0000000..8d5760c --- /dev/null +++ b/buildSrc/src/main/kotlin/extensions/LintOptions.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package extensions + +import com.android.build.gradle.internal.dsl.LintOptions +import java.io.File + +/** + * Sets default options for lint + */ +fun LintOptions.setDefaults() { + isAbortOnError = false + isWarningsAsErrors = true + isCheckDependencies = true + isIgnoreTestSources = true + lintConfig = File(".lint/lint.xml") + disable("OldTargetApi", "GradleDependency") +} diff --git a/buildSrc/src/main/kotlin/extensions/ProjectHandler.kt b/buildSrc/src/main/kotlin/extensions/ProjectHandler.kt new file mode 100644 index 0000000..d0958cb --- /dev/null +++ b/buildSrc/src/main/kotlin/extensions/ProjectHandler.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("unused") + +package extensions + +import AndroidTest +import Debug +import Main +import Release +import ReleaseConfig +import Test +import com.android.build.gradle.api.AndroidSourceSet +import com.android.build.gradle.internal.dsl.BuildType +import com.android.build.gradle.internal.dsl.SigningConfig +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension +import utils.execute +import utils.getProperty +import utils.shouldTreatCompilerWarningsAsErrors + +/** + * An extension to create release build type. + * + * @param namedDomainObjectContainer The container to create the corresponding build type + * + * @return The release [BuildType] + */ +fun Project.createRelease(namedDomainObjectContainer: NamedDomainObjectContainer) = + Release.create(namedDomainObjectContainer, this) + +/** + * An extension to create debug build type. + * + * @param namedDomainObjectContainer The container to create the corresponding build type + * + * @return The debug [BuildType] + */ +fun Project.createDebug(namedDomainObjectContainer: NamedDomainObjectContainer) = + Debug.create(namedDomainObjectContainer, this) + +/** + * An extension to create main Kotlin source set. + * + * @param namedDomainObjectContainer The container to create the corresponding source set + * + * @return The main Kotlin [AndroidSourceSet] + */ +fun Project.createKotlinMain( + namedDomainObjectContainer: NamedDomainObjectContainer +) = Main.create(namedDomainObjectContainer, this) + +/** + * An extension to create test Kotlin source set. + * + * @param namedDomainObjectContainer The container to create the corresponding source set + * + * @return The test Kotlin [AndroidSourceSet] + */ +fun Project.createKotlinTest( + namedDomainObjectContainer: NamedDomainObjectContainer +) = Test.create(namedDomainObjectContainer, this) + +/** + * An extension to create android test Kotlin source set. + * + * @param namedDomainObjectContainer The container to create the corresponding source set + * + * @return The android test Kotlin [AndroidSourceSet] + */ +fun Project.createKotlinAndroidTest( + namedDomainObjectContainer: NamedDomainObjectContainer +) = AndroidTest.create(namedDomainObjectContainer, this) + +/** + * An extension to create release signing config. + * + * @param namedDomainObjectContainer The container to create the corresponding signing config + * + * @return The release [SigningConfig] + */ +fun Project.createReleaseConfig(namedDomainObjectContainer: NamedDomainObjectContainer) = + ReleaseConfig.create(namedDomainObjectContainer, this) + +/** + * Applies semantic versioning and returns the combined version name accordingly + * + * @return The version name + */ +fun Project.getSemanticAppVersionName() = utils.getSemanticAppVersionName() + +/** + * Basically fetches the recent git commit hash + */ +internal inline val Project.gitSha: String + get() = "git rev-parse --short HEAD".execute(rootDir, "none") + +/** + * Specify whether or not treat compiler warnings as errors + */ +internal fun Project.shouldTreatCompilerWarningsAsErrors() = + shouldTreatCompilerWarningsAsErrors(this) + +/** + * If the instrumented tests live in the Android Library projects, running + * ./gradlew connectedDebugAndroidTest will run the androidTest related tasks for all the ones + * without any android tests at all. This is for performance purpose to reduce build time. + */ +internal inline val Project.hasAndroidTestSource: Boolean + get() { + extensions + .findByType(KotlinAndroidProjectExtension::class.java) + ?.sourceSets + ?.findByName("androidTest") + ?.let { + if (it.kotlin.files.isNotEmpty()) return true + } + return false + } + +/** + * Returns the requested property + * + * @param name The property name + * + * @return The property as [String] + */ +fun Project.getProperty(name: String) = getProperty(name, this) + +/** + * If the instrumented tests live in the Android Library projects, running + * ./gradlew connectedDebugAndroidTest will run the androidTest related tasks for all the ones + * without any android tests at all. This is for performance purpose to reduce build time. + */ +fun Project.configureAndroidTests() { + if (!hasAndroidTestSource) { + project.tasks.configureEach { + if (name.contains("androidTest", ignoreCase = true)) { + enabled = false + } + } + } +} diff --git a/buildSrc/src/main/kotlin/extensions/RepositoryHandler.kt b/buildSrc/src/main/kotlin/extensions/RepositoryHandler.kt new file mode 100644 index 0000000..f756483 --- /dev/null +++ b/buildSrc/src/main/kotlin/extensions/RepositoryHandler.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package extensions + +import org.gradle.api.artifacts.dsl.RepositoryHandler + +/** + * Applies default plugins for repository + */ +fun RepositoryHandler.applyDefaults() { + google() + mavenCentral() + + // Make this a caching provider + jcenter() +} diff --git a/buildSrc/src/main/kotlin/extensions/TestOptions.kt b/buildSrc/src/main/kotlin/extensions/TestOptions.kt new file mode 100644 index 0000000..3e718ba --- /dev/null +++ b/buildSrc/src/main/kotlin/extensions/TestOptions.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package extensions + +import com.android.build.gradle.internal.dsl.TestOptions + +/** + * Sets default options for test + */ +fun TestOptions.applyDefault() { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true + animationsDisabled = true +} diff --git a/buildSrc/src/main/kotlin/plugins/BuildPlugins.kt b/buildSrc/src/main/kotlin/plugins/BuildPlugins.kt new file mode 100644 index 0000000..334b9f4 --- /dev/null +++ b/buildSrc/src/main/kotlin/plugins/BuildPlugins.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package plugins + +/** + * An object that holds all the plugins + */ +object BuildPlugins { + const val DETEKT = "plugins.detekt" + const val UPDATE_DEPENDENCIES = "plugins.update-dependencies" + const val KTLINT = "plugins.ktlint" + const val SPOTLESS = "plugins.spotless" + const val GIT_HOOKS = "plugins.git-hooks" +} diff --git a/buildSrc/src/main/kotlin/plugins/detekt.gradle.kts b/buildSrc/src/main/kotlin/plugins/detekt.gradle.kts new file mode 100644 index 0000000..91aa540 --- /dev/null +++ b/buildSrc/src/main/kotlin/plugins/detekt.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package plugins + +import Versions +import io.gitlab.arturbosch.detekt.Detekt +import io.gitlab.arturbosch.detekt.DetektPlugin +import io.gitlab.arturbosch.detekt.detekt +import utils.javaVersion + +apply() + +detekt { + toolVersion = Versions.detekt + parallel = false + input = files( + "src/main/kotlin", + "src/main/java" + ) + config = files("${project.rootDir}/default-detekt-config.yml") + + reports { + xml { + enabled = true + destination = file("${project.buildDir}/reports/detekt/detekt-report.xml") + } + html { + enabled = true + destination = file("${project.buildDir}/reports/detekt/detekt-report.html") + } + } +} + +tasks { + withType { + include("**/*.kt", "**/*.kts") + exclude("**/build/**", ".*/resources/.*", ".*test.*,.*/resources/.*,.*/tmp/.*") + + jvmTarget = javaVersion.toString() + } +} diff --git a/buildSrc/src/main/kotlin/plugins/git-hooks.gradle.kts b/buildSrc/src/main/kotlin/plugins/git-hooks.gradle.kts new file mode 100644 index 0000000..974ccd3 --- /dev/null +++ b/buildSrc/src/main/kotlin/plugins/git-hooks.gradle.kts @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package plugins + +import utils.isLinuxOrMacOs + +tasks { + register("copyGitHooks") { + description = "Copies the git hooks from scripts/git-hooks to the .git folder." + group = "git hooks" + from("$rootDir/scripts/git-hooks/") { + include("**/*.sh") + rename("(.*).sh", "$1") + } + into("$rootDir/.git/hooks") + } + + register("installGitHooks") { + description = "Installs the pre-commit git hooks from scripts/git-hooks." + group = "git hooks" + workingDir(rootDir) + commandLine("chmod") + args("-R", "+x", ".git/hooks/") + dependsOn(named("copyGitHooks")) + onlyIf { + isLinuxOrMacOs() + } + doLast { + logger.info("Git hooks installed successfully.") + } + } + + register("deleteGitHooks") { + description = "Delete the pre-commit git hooks." + group = "git hooks" + delete(fileTree(".git/hooks/")) + } + + afterEvaluate { + tasks["clean"].dependsOn(tasks.named("installGitHooks")) + } +} diff --git a/buildSrc/src/main/kotlin/plugins/ktlint.gradle.kts b/buildSrc/src/main/kotlin/plugins/ktlint.gradle.kts new file mode 100644 index 0000000..92f2f9e --- /dev/null +++ b/buildSrc/src/main/kotlin/plugins/ktlint.gradle.kts @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package plugins + +import Config +import Versions +import org.jlleitschuh.gradle.ktlint.KtlintExtension +import org.jlleitschuh.gradle.ktlint.KtlintPlugin +import org.jlleitschuh.gradle.ktlint.reporter.ReporterType + +apply() + +configure { + version.set(Versions.ktlint_internal) + debug.set(true) + verbose.set(true) + android.set(false) + outputToConsole.set(true) + outputColorName.set(Config.KTLINT_COLOR_NAME) + ignoreFailures.set(true) + enableExperimentalRules.set(true) + additionalEditorconfigFile.set(file("${project.rootDir}/.editorconfig")) + reporters { + reporter(ReporterType.PLAIN) + reporter(ReporterType.CHECKSTYLE) + reporter(ReporterType.JSON) + } + kotlinScriptAdditionalPaths { + include(fileTree("scripts/")) + } + filter { + exclude("**/generated/**") + include("**/kotlin/**") + } +} diff --git a/buildSrc/src/main/kotlin/plugins/spotless.gradle.kts b/buildSrc/src/main/kotlin/plugins/spotless.gradle.kts new file mode 100644 index 0000000..e32dd4a --- /dev/null +++ b/buildSrc/src/main/kotlin/plugins/spotless.gradle.kts @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package plugins + +import Config +import com.diffplug.gradle.spotless.SpotlessExtension +import com.diffplug.gradle.spotless.SpotlessPlugin + +apply() + +@Suppress("INACCESSIBLE_TYPE") +configure { + format("misc") { + target( + fileTree( + mapOf( + "dir" to ".", + "include" to listOf("**/*.md", "**/.gitignore", "**/*.yaml", "**/*.yml"), + "exclude" to listOf( + ".gradle/**", + ".gradle-cache/**", + "**/tools/**", + "**/build/**" + ) + ) + ) + ) + trimTrailingWhitespace() + indentWithSpaces() + endWithNewline() + } + + format("xml") { + target("**/res/**/*.xml") + targetExclude("**/build/**") + indentWithSpaces(Config.SPOTLESS_INDENT_WITH_SPACES) + trimTrailingWhitespace() + endWithNewline() + } + + kotlin { + target( + fileTree( + mapOf( + "dir" to ".", + "include" to listOf("**/*.kt"), + "exclude" to listOf("**/build/**", "**/spotless/*.kt") + ) + ) + ) + licenseHeaderFile( + rootProject.file("spotless/copyright.kt"), + "^(package|object|import|interface|internal|@file|//startfile)" + ) + trimTrailingWhitespace() + indentWithSpaces() + endWithNewline() + } + + java { + target( + fileTree( + mapOf( + "dir" to ".", + "include" to listOf("**/*.java"), + "exclude" to listOf("**/build/**", "**/spotless/*.java") + ) + ) + ) + licenseHeaderFile( + rootProject.file("spotless/copyright.java"), + "^(package|object|import|interface|@file|//startfile)" + ) + removeUnusedImports() + trimTrailingWhitespace() + indentWithSpaces() + endWithNewline() + } + + kotlinGradle { + target( + fileTree( + mapOf( + "dir" to ".", + "include" to listOf("**/*.gradle.kts", "*.gradle.kts"), + "exclude" to listOf("**/build/**", "**/spotless/*.java", "**/spotless/*.kt") + ) + ) + ) + licenseHeaderFile( + rootProject.file("spotless/copyright.kt"), + "package|import|tasks|apply|plugins|include|val|object|interface|pluginManagement|@file|//startfile" + ) + trimTrailingWhitespace() + indentWithSpaces() + endWithNewline() + } +} diff --git a/buildSrc/src/main/kotlin/plugins/update-dependencies.gradle.kts b/buildSrc/src/main/kotlin/plugins/update-dependencies.gradle.kts new file mode 100644 index 0000000..de0e16b --- /dev/null +++ b/buildSrc/src/main/kotlin/plugins/update-dependencies.gradle.kts @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package plugins + +import Config +import com.github.benmanes.gradle.versions.VersionsPlugin +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask +import utils.isNonStable + +apply() + +tasks { + withType { + resolutionStrategy { + componentSelection { + all { + if (isNonStable(candidate.version) && !isNonStable(currentVersion)) { + reject("Release candidate") + } + } + } + } + + checkForGradleUpdate = true + outputFormatter = Config.JSON_OUTPUT_FORMATTER + reportfileName = "dependency-report" + outputDir = "${project.buildDir}/reports/dependencyUpdates" + } +} diff --git a/buildSrc/src/main/kotlin/tasks/BuildTasks.kt b/buildSrc/src/main/kotlin/tasks/BuildTasks.kt new file mode 100644 index 0000000..a4742a8 --- /dev/null +++ b/buildSrc/src/main/kotlin/tasks/BuildTasks.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tasks + +/** + * An object that holds all the tasks + */ +object BuildTasks { + const val COMMON_TASKS = "tasks.common-tasks" +} diff --git a/buildSrc/src/main/kotlin/tasks/common-tasks.gradle.kts b/buildSrc/src/main/kotlin/tasks/common-tasks.gradle.kts new file mode 100644 index 0000000..dc0f0d6 --- /dev/null +++ b/buildSrc/src/main/kotlin/tasks/common-tasks.gradle.kts @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tasks + +import extensions.shouldTreatCompilerWarningsAsErrors +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import utils.javaVersion +import utils.parallelForks + +tasks { + withType { + options.isIncremental = true + allprojects { + options.compilerArgs.addAll( + arrayOf( + "-Xlint:-unchecked", + "-Xlint:deprecation", + "-Xdiags:verbose" + ) + ) + } + } + + withType { + kotlinOptions { + jvmTarget = javaVersion.toString() + // https://youtrack.jetbrains.com/issue/KT-24946 + kotlinOptions.freeCompilerArgs = listOf( + "-progressive", + "-Xskip-runtime-version-check", + "-Xdisable-default-scripting-plugin", + "-Xuse-experimental=kotlin.Experimental", + "-Xopt-in=kotlin.RequiresOptIn" + ) + kotlinOptions.allWarningsAsErrors = project.shouldTreatCompilerWarningsAsErrors() + } + } + + withType { + testLogging { + // set options for log level LIFECYCLE + events = setOf( + TestLogEvent.FAILED, + TestLogEvent.STARTED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_OUT + ) + exceptionFormat = TestExceptionFormat.FULL + showExceptions = true + showCauses = true + showStackTraces = true + } + + maxParallelForks = parallelForks + } +} diff --git a/buildSrc/src/main/kotlin/utils/DependencyUtils.kt b/buildSrc/src/main/kotlin/utils/DependencyUtils.kt new file mode 100644 index 0000000..136df6c --- /dev/null +++ b/buildSrc/src/main/kotlin/utils/DependencyUtils.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package utils + +import Config +import java.util.* + +/** + * A helper function to check whether or not requested dependency is up-to-date + * + * @param version The version + * + * @return true if the dependency is under any of the specified version suffix otherwise false + */ +fun isNonStable(version: String): Boolean { + val stableKeyword = + listOf("RELEASE", "FINAL", "GA").any { version.toUpperCase(Locale.ROOT).contains(it) } + val regex = Config.BUILD_STABLE_REGEX.toRegex() + val isStable = stableKeyword || regex.matches(version) + return isStable.not() +} diff --git a/buildSrc/src/main/kotlin/utils/PropertyUtils.kt b/buildSrc/src/main/kotlin/utils/PropertyUtils.kt new file mode 100644 index 0000000..b087196 --- /dev/null +++ b/buildSrc/src/main/kotlin/utils/PropertyUtils.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package utils + +import org.gradle.api.Project +import java.io.File +import java.util.* + +private const val VERSION_PROPERTIES_FILE_NAME = "keystore.properties" + +/** + * Returns the requested properties file + * + * @param project The main project + * + * @return property [File] + */ +internal fun getFile(project: Project) = project.rootProject.file(VERSION_PROPERTIES_FILE_NAME) + +/** + * Returns the requested property + * + * @param name The property name + * @param project The main project + * + * @return The property as [String] + */ +internal fun getProperty(name: String, project: Project): String { + val properties = Properties().apply { + val versionProperties = getFile(project) + if (versionProperties.exists()) { + load(versionProperties.inputStream()) + } + } + + return properties.getProperty(name) +} diff --git a/buildSrc/src/main/kotlin/utils/StringUtils.kt b/buildSrc/src/main/kotlin/utils/StringUtils.kt new file mode 100644 index 0000000..764cd12 --- /dev/null +++ b/buildSrc/src/main/kotlin/utils/StringUtils.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package utils + +import java.io.File + +/** + * Executes the given command in specified working dir + * + * @param workingDir represents dir where executable command will be executed + * @param fallback replacement String in case source is empty + */ +internal fun String?.execute(workingDir: File, fallback: String): String { + Runtime.getRuntime().exec(this, null, workingDir).let { + it.waitFor() + return try { + it.inputStream.reader().readText().trim().letIfEmpty(fallback) + } catch (e: Exception) { + fallback + } + } +} + +/** + * Lets another string to be used in case source is empty + * + * @param fallback replacement String if source is empty + */ +internal fun String?.letIfEmpty(fallback: String): String { + return if (this == null || isEmpty()) fallback else this +} + +/** + * Applies semantic versioning and returns the combined version name accordingly + * + * @return The version name + */ +internal fun getSemanticAppVersionName(): String { + val majorCode = 1 + val minorCode = 0 + val patchCode = 0 + + return "$majorCode.$minorCode.$patchCode" +} diff --git a/buildSrc/src/main/kotlin/utils/SystemUtils.kt b/buildSrc/src/main/kotlin/utils/SystemUtils.kt new file mode 100644 index 0000000..0f38c47 --- /dev/null +++ b/buildSrc/src/main/kotlin/utils/SystemUtils.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package utils + +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import java.util.* + +/** + * Returns the corresponding [JavaVersion] + */ +inline val javaVersion: JavaVersion get() = JavaVersion.VERSION_1_8 + +/** + * Returns the parallel forks for testing + */ +internal inline val parallelForks: Int + get() = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 + +/** + * Usage: ./gradlew build -PwarningsAsErrors=true. + */ +internal fun shouldTreatCompilerWarningsAsErrors(project: Project): Boolean { + return project.findProperty("warningsAsErrors") == "true" +} + +/** + * Util to check if the project run on Linux or Mac operating system + * + * @return true if the operating system is one of them + */ +fun isLinuxOrMacOs(): Boolean { + val osName = System.getProperty("os.name").toLowerCase(Locale.ROOT) + return listOf("linux", "mac os", "macos").contains(osName) +} diff --git a/default-detekt-config.yml b/default-detekt-config.yml new file mode 100644 index 0000000..65f9ed9 --- /dev/null +++ b/default-detekt-config.yml @@ -0,0 +1,613 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ClassCountProcessor' + # - 'PackageCountProcessor' + # - 'KtFileCountProcessor' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + # - 'FindingsReport' + - 'FileBasedFindingsReport' + +comments: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + UndocumentedPublicClass: + active: false + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + UndocumentedPublicProperty: + active: false + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: run,let,apply,with,also,use,forEach,isNotNull,ifNull + LabeledExpression: + active: false + ignoredLabels: '' + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + threshold: 6 + ignoreDefaultParameters: false + MethodOverloading: + active: false + threshold: 6 + NestedBlockDepth: + active: true + threshold: 4 + StringLiteralDuplication: + active: false + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + thresholdInFiles: 11 + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + RedundantSuspendModifier: + active: false + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '^(_|(ignore|expected).*)' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: false + methodNames: 'toString,hashCode,equals,finalize' + InstanceOfCheckForException: + active: false + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + NotImplementedDeclaration: + active: false + PrintStackTrace: + active: false + RethrowCaughtException: + active: false + ReturnFromFinally: + active: false + ignoreLabeled: false + SwallowedException: + active: false + ignoredExceptionTypes: 'InterruptedException,NumberFormatException,ParseException,MalformedURLException' + allowedExceptionNameRegex: '^(_|(ignore|expected).*)' + ThrowingExceptionFromFinally: + active: false + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + exceptions: 'IllegalArgumentException,IllegalStateException,IOException' + ThrowingNewInstanceOfSameException: + active: false + TooGenericExceptionCaught: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + exceptionNames: + - ArrayIndexOutOfBoundsException + - Error + - Exception + - IllegalMonitorStateException + - NullPointerException + - IndexOutOfBoundsException + - RuntimeException + - Throwable + allowedExceptionNameRegex: '^(_|(ignore|expected).*)' + TooGenericExceptionThrown: + active: true + exceptionNames: + - Error + - Exception + - Throwable + - RuntimeException + +formatting: + active: true + android: false + autoCorrect: true + AnnotationOnSeparateLine: + active: false + autoCorrect: true + ChainWrapping: + active: true + autoCorrect: true + CommentSpacing: + active: true + autoCorrect: true + EnumEntryNameCase: + active: false + autoCorrect: true + Filename: + active: true + FinalNewline: + active: true + autoCorrect: true + insertFinalNewLine: true + ImportOrdering: + active: false + autoCorrect: true + Indentation: + active: false + autoCorrect: true + indentSize: 4 + continuationIndentSize: 4 + MaximumLineLength: + active: true + maxLineLength: 120 + ModifierOrdering: + active: true + autoCorrect: true + MultiLineIfElse: + active: true + autoCorrect: true + NoBlankLineBeforeRbrace: + active: true + autoCorrect: true + NoConsecutiveBlankLines: + active: true + autoCorrect: true + NoEmptyClassBody: + active: true + autoCorrect: true + NoEmptyFirstLineInMethodBlock: + active: false + autoCorrect: true + NoLineBreakAfterElse: + active: true + autoCorrect: true + NoLineBreakBeforeAssignment: + active: true + autoCorrect: true + NoMultipleSpaces: + active: true + autoCorrect: true + NoSemicolons: + active: true + autoCorrect: true + NoTrailingSpaces: + active: true + autoCorrect: true + NoUnitReturn: + active: true + autoCorrect: true + NoUnusedImports: + active: true + autoCorrect: true + NoWildcardImports: + active: true + PackageName: + active: true + autoCorrect: true + ParameterListWrapping: + active: true + autoCorrect: true + indentSize: 4 + SpacingAroundColon: + active: true + autoCorrect: true + SpacingAroundComma: + active: true + autoCorrect: true + SpacingAroundCurly: + active: true + autoCorrect: true + SpacingAroundDot: + active: true + autoCorrect: true + SpacingAroundKeyword: + active: true + autoCorrect: true + SpacingAroundOperators: + active: true + autoCorrect: true + SpacingAroundParens: + active: true + autoCorrect: true + SpacingAroundRangeOperator: + active: true + autoCorrect: true + StringTemplate: + active: true + autoCorrect: true + +naming: + active: true + ClassNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + classPattern: '[A-Z$][a-zA-Z0-9$]*' + ConstructorParameterNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + EnumNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + enumEntryPattern: '^[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + forbiddenName: '' + FunctionMaxLength: + active: false + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + functionPattern: '^([a-z$][a-zA-Z$0-9]*)|(`.*`)$' + excludeClassPattern: '$^' + ignoreOverridden: true + FunctionParameterNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + InvalidPackageDeclaration: + active: false + rootPackage: '' + MatchingDeclarationName: + active: true + mustBeFirst: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + ObjectPropertyNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + packagePattern: '^[a-z]+(\.[a-z][A-Za-z0-9]*)*$' + TopLevelPropertyNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + maximumVariableNameLength: 64 + VariableMinLength: + active: false + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + minimumVariableNameLength: 1 + VariableNaming: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + ignoreOverridden: true + +performance: + active: true + ArrayPrimitive: + active: true + ForEachOnRange: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + SpreadOperator: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + Deprecation: + active: false + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: false + ImplicitDefaultLocale: + active: false + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + excludeAnnotatedProperties: '' + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: false + MissingWhenCase: + active: true + RedundantElseInWhen: + active: true + UnconditionalJumpStatementInLoop: + active: false + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: false + UselessPostfixExpression: + active: false + WrongEqualsTypeParameter: + active: true + +style: + active: true + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: 'to' + DataClassShouldBeImmutable: + active: false + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: false + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenComment: + active: true + values: 'TODO:,FIXME:,STOPSHIP:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: '' + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: '' + ForbiddenPublicDataClass: + active: false + ignorePackages: '*.internal,*.internal.*' + ForbiddenVoid: + active: false + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + excludedFunctions: 'describeContents' + excludeAnnotatedFunction: 'dagger.Provides' + LibraryCodeMustSpecifyReturnType: + active: true + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + ignoreNumbers: '-1,0,1,2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + MandatoryBracesIfStatements: + active: false + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + MayBeConst: + active: true + ModifierOrder: + active: true + NestedClassesVisibility: + active: false + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + OptionalWhenBraces: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 2 + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableDecimalLength: 5 + UnnecessaryAbstractClass: + active: true + excludeAnnotatedClasses: 'dagger.Module' + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: false + UnnecessaryInheritance: + active: true + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: false + allowedNames: '(_|ignored|expected|serialVersionUID)' + UseArrayLiteralsInAnnotations: + active: false + UseCheckOrError: + active: false + UseDataClass: + active: false + excludeAnnotatedClasses: '' + allowVars: false + UseIfInsteadOfWhen: + active: false + UseRequire: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: false + WildcardImport: + active: true + excludes: '**/test/**,**/androidTest/**,**/*.Test.kt,**/*.Spec.kt,**/*.Spek.kt' + excludeImports: 'java.util.*,kotlinx.android.synthetic.*' diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5d75969 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,42 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Caching gradle will caches task outputs from any previous build from any location +org.gradle.daemon=true +org.gradle.configureondemand=true +org.gradle.parallel=true +org.gradle.caching=true +android.enableBuildCache=true +# Kapt can reuse gradle workers +kapt.incremental.apt=true +kapt.use.worker.api=true +kapt.include.compile.classpath=false +# Enables new data binding compiler: https://developer.android.com/topic/libraries/data-binding/index.html +android.databinding.enableV2=true +# R8 +android.enableR8=true +android.enableR8.fullMode=true +# Versions +compileSdkVersion=29 +minSdkVersion=21 +targetSdkVersion=29 +# Network +baseUrl="https://hiring.revolut.codes/" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b359875 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Apr 01 16:04:55 CEST 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/keystore.properties b/keystore.properties new file mode 100644 index 0000000..3f43378 --- /dev/null +++ b/keystore.properties @@ -0,0 +1,4 @@ +signing.store.password=revolut +signing.key.password=revolut +signing.key.alias=Libbra +signing.store.file=keystore/release.jks diff --git a/keystore/release.jks b/keystore/release.jks new file mode 100644 index 0000000..b8ba9a9 Binary files /dev/null and b/keystore/release.jks differ diff --git a/rules/.gitignore b/rules/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/rules/.gitignore @@ -0,0 +1 @@ +/build diff --git a/rules/build.gradle.kts b/rules/build.gradle.kts new file mode 100644 index 0000000..b738d65 --- /dev/null +++ b/rules/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import dependencies.Dependencies + +plugins { + `java-library` + kotlin +} + +java { + sourceSets { + getByName("main") { + java.srcDir("src/main/kotlin") + } + getByName("test") { + java.srcDir("src/test/kotlin") + } + } +} + +dependencies { + compileOnly(Dependencies.Lint.lint) + compileOnly(Dependencies.Lint.api) + compileOnly(Dependencies.Lint.checks) + compileOnly(Dependencies.Lint.tests) + + testImplementation(Dependencies.Lint.tests) +} diff --git a/rules/src/main/kotlin/io/github/nuhkoca/rules/IssueRegistry.kt b/rules/src/main/kotlin/io/github/nuhkoca/rules/IssueRegistry.kt new file mode 100644 index 0000000..982af88 --- /dev/null +++ b/rules/src/main/kotlin/io/github/nuhkoca/rules/IssueRegistry.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.rules + +import com.android.tools.lint.client.api.IssueRegistry +import com.android.tools.lint.detector.api.CURRENT_API +import com.android.tools.lint.detector.api.Issue + +@Suppress("UnstableApiUsage") +class IssueRegistry : IssueRegistry() { + + override val api: Int = CURRENT_API + + override val issues: List + get() = listOf(TimberLogDetector.ISSUE) +} diff --git a/rules/src/main/kotlin/io/github/nuhkoca/rules/TimberLogDetector.kt b/rules/src/main/kotlin/io/github/nuhkoca/rules/TimberLogDetector.kt new file mode 100644 index 0000000..4481e5b --- /dev/null +++ b/rules/src/main/kotlin/io/github/nuhkoca/rules/TimberLogDetector.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.rules + +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.intellij.psi.PsiMethod +import org.jetbrains.uast.UCallExpression + +/** + * The detector which is able to find incorrect log calls for Timber. + */ +@Suppress("UnstableApiUsage") +class TimberLogDetector : Detector(), SourceCodeScanner { + + override fun getApplicableMethodNames(): List = listOf("v", "d", "i", "w", "e", "wtf") + + override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) { + super.visitMethodCall(context, node, method) + val evaluator = context.evaluator + if (evaluator.isMemberInClass(method, "timber.log.Timber")) { + reportUsage(context, node) + } + } + + /** + * Reports incorrect usage about directly call of Timber. + * + * @param context The [JavaContext] + * @param node The node represents a call expression + */ + private fun reportUsage(context: JavaContext, node: UCallExpression) { + context.report( + issue = ISSUE, + scope = node, + location = context.getCallLocation( + call = node, + includeReceiver = true, + includeArguments = true + ), + message = "Directly calling timber.log.Timber usage is not recommended." + ) + } + + companion object { + private val IMPLEMENTATION = Implementation( + TimberLogDetector::class.java, + Scope.JAVA_FILE_SCOPE + ) + + val ISSUE: Issue = Issue + .create( + id = "TimberLogDetector", + briefDescription = "The Timber should not be called directly", + explanation = """ + Timber should not be called directly. Use the extension functions instead. + """.trimIndent(), + category = Category.CORRECTNESS, + priority = 9, + severity = Severity.WARNING, + androidSpecific = true, + implementation = IMPLEMENTATION + ) + } +} diff --git a/rules/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry b/rules/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry new file mode 100644 index 0000000..be25a55 --- /dev/null +++ b/rules/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry @@ -0,0 +1 @@ +io.github.nuhkoca.rules.IssueRegistry \ No newline at end of file diff --git a/rules/src/test/kotlin/io/github/nuhkoca/rules/Stubs.kt b/rules/src/test/kotlin/io/github/nuhkoca/rules/Stubs.kt new file mode 100644 index 0000000..9d7aa91 --- /dev/null +++ b/rules/src/test/kotlin/io/github/nuhkoca/rules/Stubs.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.rules + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.java +import com.android.tools.lint.checks.infrastructure.TestFile +import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin + +/** + * A helper class that contains necessary stubs to test lints. + */ +object Stubs { + + /** + * [TestFile] containing Timber. + * + * This is a hacky workaround for the Timber not being included on the Lint test harness + * classpath. Ideally, we'd specify ANDROID_HOME as an environment variable. + */ + val TIMBER_LOG_IMPL_JAVA: TestFile = java( + """ + package timber.log; + + public final class Timber { + public static void d(@NonNls String message, Object... args) { + // Stub! + } + } + """ + ).indented() + + val CUSTOM_LOG_IMPL_KOTLIN: TestFile = kotlin( + """ + package io.github.nuhkoca.libbra.util.ext + + inline fun d(crossinline message: () -> String) = log { Timber.d(message()) } + """.trimIndent() + ) +} diff --git a/rules/src/test/kotlin/io/github/nuhkoca/rules/TimberLogDetectorTest.kt b/rules/src/test/kotlin/io/github/nuhkoca/rules/TimberLogDetectorTest.kt new file mode 100644 index 0000000..68774d6 --- /dev/null +++ b/rules/src/test/kotlin/io/github/nuhkoca/rules/TimberLogDetectorTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.rules + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import io.github.nuhkoca.rules.Stubs.CUSTOM_LOG_IMPL_KOTLIN +import io.github.nuhkoca.rules.Stubs.TIMBER_LOG_IMPL_JAVA +import org.junit.Test + +/** + * A test class for [TimberLogDetector] + */ +@Suppress("UnstableApiUsage") +class TimberLogDetectorTest : LintDetectorTest() { + + @Test + fun `test should detect usage of Timber`() { + val stubFile = kotlin( + """ + package io.github.nuhkoca.libbra + + import timber.log.Timber + + class Dog { + + fun bark() { + Timber.d("woof! woof!") + } + } + """ + ).indented() + + val lintResult = lint() + .files(TIMBER_LOG_IMPL_JAVA, stubFile) + .run() + + lintResult + .expectWarningCount(1) + .expect( + """ + src/io/github/nuhkoca/libbra/Dog.kt:8: Warning: Directly calling timber.log.Timber usage is not recommended. [TimberLogDetector] + Timber.d("woof! woof!") + ~~~~~~~~~~~~~~~~~~~~~~~ + 0 errors, 1 warnings + """.trimIndent() + ) + } + + @Test + fun `test should not detect if log import is different`() { + val fileToEvaluate = kotlin( + """ + package io.github.nuhkoca.libbra + + import io.github.nuhkoca.libbra.util.ext.d + + class Dog { + fun bark() { + d { "woof! woof!" } + } + } + """ + ).indented() + + val lintResult = lint() + .files(CUSTOM_LOG_IMPL_KOTLIN, fileToEvaluate) + .run() + + lintResult + .expectClean() + } + + override fun getDetector(): Detector = TimberLogDetector() + + override fun getIssues(): MutableList = mutableListOf(TimberLogDetector.ISSUE) +} diff --git a/rules/src/test/kotlin/io/github/nuhkoca/rules/UnitTestSuite.kt b/rules/src/test/kotlin/io/github/nuhkoca/rules/UnitTestSuite.kt new file mode 100644 index 0000000..e7d7866 --- /dev/null +++ b/rules/src/test/kotlin/io/github/nuhkoca/rules/UnitTestSuite.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.nuhkoca.rules + +import org.junit.runner.RunWith +import org.junit.runners.Suite + +/** + * A unit test suite to execute all the test classes under this module. + */ +@RunWith(Suite::class) +@Suite.SuiteClasses( + TimberLogDetectorTest::class +) +object UnitTestSuite diff --git a/scripts/git-hooks/pre-commit.sh b/scripts/git-hooks/pre-commit.sh new file mode 100644 index 0000000..03196e9 --- /dev/null +++ b/scripts/git-hooks/pre-commit.sh @@ -0,0 +1,23 @@ +#!/bin/sh +echo "Running static analysis..." + +JAVA_HOME=$(/usr/libexec/java_home -v 1.8) +export JAVA_HOME + +OUTPUT="/tmp/analysis-result" +./gradlew lintDebug detekt ktlintCheck spotlessApply --daemon >${OUTPUT} +EXIT_CODE=$? +if [ ${EXIT_CODE} -ne 0 ]; then + cat ${OUTPUT} + rm ${OUTPUT} + echo "*********************************************" + echo " Static Analysis Failed " + echo "Please fix the above issues before committing" + echo "*********************************************" + exit ${EXIT_CODE} +else + rm ${OUTPUT} + echo "*********************************************" + echo " Static analysis no problems found " + echo "*********************************************" +fi diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..fcbdef6 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + @Suppress("UnstableApiUsage") + `gradle-enterprise` +} + +gradleEnterprise { + buildScan { + termsOfServiceUrl = "https://gradle.com/terms-of-service" + termsOfServiceAgree = "yes" + } +} + +include(":app", ":rules") + +rootProject.name = "Libbra" +rootProject.buildFileName = "build.gradle.kts" diff --git a/spotless/copyright.java b/spotless/copyright.java new file mode 100644 index 0000000..e984cbd --- /dev/null +++ b/spotless/copyright.java @@ -0,0 +1,15 @@ +/* + * Copyright (C) $YEAR. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/spotless/copyright.kt b/spotless/copyright.kt new file mode 100644 index 0000000..e984cbd --- /dev/null +++ b/spotless/copyright.kt @@ -0,0 +1,15 @@ +/* + * Copyright (C) $YEAR. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/versions.gradle.kts b/versions.gradle.kts new file mode 100644 index 0000000..7728b9e --- /dev/null +++ b/versions.gradle.kts @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2020. Nuh Koca. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +val compileSdkVersion: String by project +val minSdkVersion: String by project +val targetSdkVersion: String by project + +extra.apply { + this["compileSdkVersion"] = compileSdkVersion.toInt() + this["minSdkVersion"] = minSdkVersion.toInt() + this["targetSdkVersion"] = targetSdkVersion.toInt() +}