Repository: BracketCove/SpaceNotes
Branch: master
Commit: d799560fbb94
Files: 115
Total size: 230.6 KB
Directory structure:
gitextract_qf805k6a/
├── .gitignore
├── README.md
├── app/
│ ├── .gitignore
│ ├── build.gradle
│ ├── proguard-rules.pro
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── wiseassblog/
│ │ │ └── spacenotes/
│ │ │ ├── SpaceNotes.kt
│ │ │ ├── common/
│ │ │ │ ├── AndroidExt.kt
│ │ │ │ ├── BaseLogic.kt
│ │ │ │ ├── Constants.kt
│ │ │ │ └── Navigation.kt
│ │ │ ├── login/
│ │ │ │ ├── ILoginContract.kt
│ │ │ │ ├── LoginActivity.kt
│ │ │ │ ├── LoginLogic.kt
│ │ │ │ ├── LoginResult.kt
│ │ │ │ └── buildlogic/
│ │ │ │ └── LoginInjector.kt
│ │ │ ├── notedetail/
│ │ │ │ ├── INoteDetailContract.kt
│ │ │ │ ├── NoteDetailActivity.kt
│ │ │ │ ├── NoteDetailLogic.kt
│ │ │ │ ├── NoteDetailView.kt
│ │ │ │ ├── NoteDetailViewModel.kt
│ │ │ │ └── buildlogic/
│ │ │ │ └── NoteDetailInjector.kt
│ │ │ └── notelist/
│ │ │ ├── INoteListContract.kt
│ │ │ ├── NoteDiffUtilCallback.kt
│ │ │ ├── NoteListActivity.kt
│ │ │ ├── NoteListAdapter.kt
│ │ │ ├── NoteListLogic.kt
│ │ │ ├── NoteListView.kt
│ │ │ ├── NoteListViewModel.kt
│ │ │ └── buildlogic/
│ │ │ └── NoteListInjector.kt
│ │ └── res/
│ │ ├── drawable/
│ │ │ ├── antenna_loop.xml
│ │ │ ├── antenna_loop_fast.xml
│ │ │ ├── ic_access_time_black_24dp.xml
│ │ │ ├── ic_arrow_back_black_24dp.xml
│ │ │ ├── ic_baseline_add_24px.xml
│ │ │ ├── ic_baseline_event_24px.xml
│ │ │ ├── ic_delete_forever_black_24dp.xml
│ │ │ ├── ic_done_black_24dp.xml
│ │ │ ├── ic_launcher_background.xml
│ │ │ ├── ic_visibility_off_black_24dp.xml
│ │ │ ├── ic_vpn_key_black_24dp.xml
│ │ │ ├── satellite_beam.xml
│ │ │ └── space_loop.xml
│ │ ├── drawable-v24/
│ │ │ └── ic_launcher_foreground.xml
│ │ ├── layout/
│ │ │ ├── activity_login.xml
│ │ │ ├── activity_note_detail.xml
│ │ │ ├── activity_note_list.xml
│ │ │ ├── activity_user_auth.xml
│ │ │ ├── fragment_note_detail.xml
│ │ │ ├── fragment_note_list.xml
│ │ │ └── item_note.xml
│ │ ├── mipmap-anydpi-v26/
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ └── values/
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ ├── styles.xml
│ │ └── view_styles.xml
│ └── test/
│ └── java/
│ └── com/
│ └── wiseassblog/
│ └── spacenotes/
│ ├── LoginLogicTest.kt
│ ├── NoteDetailLogicTest.kt
│ └── NoteListLogicTest.kt
├── build.gradle
├── data/
│ ├── .gitignore
│ ├── build.gradle
│ ├── proguard-rules.pro
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── wiseassblog/
│ │ │ └── data/
│ │ │ ├── DataExt.kt
│ │ │ ├── auth/
│ │ │ │ └── FirebaseAuthRepositoryImpl.kt
│ │ │ ├── datamodels/
│ │ │ │ ├── AnonymousRoomNote.kt
│ │ │ │ ├── FirebaseNote.kt
│ │ │ │ ├── RegisteredRoomNote.kt
│ │ │ │ └── RegisteredRoomTransaction.kt
│ │ │ ├── note/
│ │ │ │ ├── anonymous/
│ │ │ │ │ ├── AnonymousNoteDao.kt
│ │ │ │ │ ├── RoomAnonymousNoteDatabase.kt
│ │ │ │ │ └── RoomLocalAnonymousRepositoryImpl.kt
│ │ │ │ ├── public/
│ │ │ │ │ └── FirestorePublicNoteRepositoryImpl.kt
│ │ │ │ └── registered/
│ │ │ │ ├── FirestorePrivateRemoteNoteImpl.kt
│ │ │ │ ├── RegisteredNoteDao.kt
│ │ │ │ ├── RegisteredNoteRepositoryImpl.kt
│ │ │ │ ├── RegisteredTransactionDao.kt
│ │ │ │ ├── RoomLocalCacheImpl.kt
│ │ │ │ └── RoomRegisteredNoteDatabase.kt
│ │ │ └── transaction/
│ │ │ ├── RoomRegisteredTransactionDatabase.kt
│ │ │ └── RoomTransactionRepositoryImpl.kt
│ │ └── res/
│ │ └── values/
│ │ └── strings.xml
│ └── test/
│ └── java/
│ └── com/
│ └── wiseassblog/
│ └── data/
│ ├── ExtTest.kt
│ └── RegisteredNoteRepositoryTest.kt
├── domain/
│ ├── .gitignore
│ ├── build.gradle
│ └── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── wiseassblog/
│ │ └── domain/
│ │ ├── DispatcherProvider.kt
│ │ ├── domainmodel/
│ │ │ ├── Note.kt
│ │ │ ├── NoteTransaction.kt
│ │ │ ├── Result.kt
│ │ │ └── User.kt
│ │ ├── error/
│ │ │ └── SpaceNotesError.kt
│ │ ├── interactor/
│ │ │ ├── AnonymousNoteSource.kt
│ │ │ ├── AuthSource.kt
│ │ │ ├── PublicNoteSource.kt
│ │ │ └── RegisteredNoteSource.kt
│ │ ├── repository/
│ │ │ ├── IAuthRepository.kt
│ │ │ ├── ILocalNoteRepository.kt
│ │ │ ├── IPublicNoteRepository.kt
│ │ │ ├── IRemoteNoteRepository.kt
│ │ │ └── ITransactionRepository.kt
│ │ └── servicelocator/
│ │ ├── NoteServiceLocator.kt
│ │ └── UserServiceLocator.kt
│ └── test/
│ └── java/
│ └── com/
│ └── wiseassblog/
│ └── domain/
│ ├── AnonymousNoteSourceTest.kt
│ ├── PublicNoteSourceTest.kt
│ └── RegisteredNoteSourceTest.kt
├── gradle/
│ └── wrapper/
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
└── versions.gradle
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
*.iml
.gradle
/local.properties
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
.DS_Store
/build
/captures
.externalNativeBuild
================================================
FILE: README.md
================================================
### Please Note:
- Since I see people are still looking at this repo, I want to be clear that I no longer recommend multi-module (i.e. multiple gradle subprojects) architecture unless you actually have a good reason for it. In this case, it really just adds extra complexity for no real benefit. However, multi-platform projects would be a great example of a situation where you'd want to use multi-module.
# SpaceNotes
New to Kotlin? Whether you are a seasoned Java veteran, or you're just starting
out with Kotlin for Android, consider checking out [Application Programming
Fundamentals w/ Kotlin](https://www.udemy.com/application-programming-fundamentals-with-kotlin/learn/v4/overview). If you like my videos and repositories, I think
you'll really enjoy a more polished course experience from wiseAss!
## What is SpaceNotes?
SpaceNotes is a Kotlin based Android Application, which was built with
best practices an innovation in mind. The app uses Coroutines for
concurrency and cross-module/boundary communication, a Clean Domain
Layer to allow the application to work properly across multiple platforms,
and a few of my favourite APIs from Android Architecture Component
and Firebase.
## Software Architecture
### Feature Specific (Front End Android):
#### NoteList Feature
This feature displays whatever Notes are currently available based on the user's status, such as: Anonymous, Registered Private, Registered Public
* INoteListContract specifies the different interactions between classes and the events which may occur in this particular feature
* NoteListActivity is a feature level “container”, within which these different things are deployed in to (it is also the entry point of the feature)
* NoteListLogic is the “decision maker” of the feature, which handles the events and interactions specified in the contract (this kind of class is the most important to test)
* NoteListView contains logic and bindings to the user interface
* NoteListAdapter contains a decoupled RecyclerView.ListAdapter w/ DiffUtil
* NoteListViewModel contains the most recent data which has been returned from the “backend” of the application (or data which is passed into the feature via navigation), and persists this data so that the logic class or view does not need to (if they did, it would break the separation of concerns)
* NoteListInjector: Build logic (Dpependency Injection Implementation) for this feature.
#### NoteDetail Feature
This feature allows the User to view, update, create, and delete a Note. Data is stored in various local/remote datasources based on whether the user is or isn't logged in, and if they are in public or private mode.
* INoteDetailContract specifies the different interactions between classes and the events which may occur in this particular feature
* NoteDetailActivity is a feature level “container”, within which these different things are deployed in to (it is also the entry point of the feature)
* NoteDetailLogic is the “decision maker” of the feature, which handles the events and interactions specified in the contract (this kind of class is the most important to test)
* NoteDetailView contains logic and bindings to the user interface
* NoteDetailViewModel contains the most recent data which has been returned from the “backend” of the application (or data which is passed into the feature via navigation), and persists this data so that the logic class or view does not need to (if they did, it would break the separation of concerns)
* NoteDetailInjector: Build logic (Dpependency Injection Implementation) for this feature.
#### Login Feature
This feature allows the User to authenticate with GoogleSignIn; which
is currently the only supported sign in function. No passwords or in-app
Sign up is required.
**Note:** I normally advocate against using Activities as Views, but I
ran in to a tight-coupling problem with GoogleSignIn API (which requires you
to override Activity.onActivityResult(...). Given this tight coupling,
and the simplicity of this feature (it only has two buttons including the toolbar), I decided to just use the Activity as a pragmatic decision.
* ILoginContract specifies the different interactions between classes and the events which may occur in this particular feature
* LoginActivity acts as the View and Container in this feature (for reasons mentioned above)
* LoginLogic is the “decision maker” of the feature, which handles the events and interactions specified in the contract (this kind of class is the most important to test)
* LoginResult Wrapper for when GoogleSignInProviders does it's thing (logging a User In)
* LoginInjector: Build logic (Dpependency Injection Implementation) for this feature.
#### Common:
* Navigation.kt: Contains Top-level functions for starting each feature with the appropriate arguments.
* Constants.kt: Contains messages and keys for front end Android
* BaseLogic.kt: Abstract class for Logic classes. Could be optimized, currently just contains a DispatcherProvider (for Coroutines) as a property, and a Job object for keeping track and disposing in-flight coroutines.
* AndroidExt.kt: Some handy Extensions functions for front end Android
### Domain:
The Domain Layer of this application has three primary purposes:
* Abstraction of the Data Layer of the application
* Providing a common, 3rd party library free set of Models (such as Note.kt) for different platforms of the App
* Providing a High-Level description of the applications primary functions based on problem domain analysis (such as User Stories)
#### Packages:
* domainmodel: POKOs (Plain Old Kotlin Objects) to be shared as a common model between different modules
* error: Sealed Class which contains application specific errors
* interactor: Interactors exist to coordinate the back end data sources. This is generally only necessary when there is more than one back data source, otherwise it ends up being
another unnecessary layer of abstraction over the repository.
* repository: Repository Interfaces which dictate the contractual obligations of each part of the back end. This allows the domain layer to coordinate the different back end components without needing to know their real implementations/libraries/dependencies.
* servicelocator: Service Locator promotes Functional Purity of the Interactor's functions, and acts as a method of Dependency Injection (by providing the dependencies as arguments to functions)
* DispatcherProvider: Used for Coroutines Implementation
### Data (Android Back End):
The Data Layer of this application contains implementations of the data sources which are described in the repository package of the domain layer.
#### Auth:
Currently implemented with FirebaseAuth; manages user authentication.
#### Data Models:
API specific data models, which are mapped from/to domain models. Each data model is created for a particular API, such as Firestore and Room.
#### Note:
Implementations for the anonymous, registered private, and registered public data sources which persist Note objects.
#### Transaction:
Transaction is only used for registered private users. It's purpose is to store offline transactions that the user makes to their registered repository, and attempts to push those transactions when the user reconnects to the remote firestore database.
#### DataExt.kt:
Contains all of the obnoxious but necessary Data Model Mapping functions, and some Coroutine wrappers over Firebase/GMS Tasks API
## Can I use code from this Repo?
Absolutely, pursuant to the project's [LICENSE](LICENSE.md). That being said, the logo and name are my intellectual creations, so don't use them unless you are linking/reffering to this Repo.
Follow the rules in the license, and you're good.
## Architecture Style:
This project uses Model-View-Whatever. It is the software architecture that has no particular style, yet accommodates all situations. In a less Zen way of speaking, I don't follow MVP, MVC, or MVVM strictly. I use parts of all styles of architectures based on whatever feature I'm creating, and that is what dictates the ultimate architecture of a given feature.
If you want me to explain in slightly more familiar terms, I basically apply MVP + VM as a front-end session datastore (such as arguments passed in from Activity Intents, current user states, current note to be displayed). I don't use ViewModels as Decision Maker Classes, hence the Logic class.
## Contact/Support me:
Follow the wiseAss Community:
https://www.instagram.com/wiseassbrand/
https://www.facebook.com/wiseassblog/
https://twitter.com/wiseass301
http://wiseassblog.com/
https://www.linkedin.com/in/ryan-kay-808388114
Support wiseAss here:
https://www.paypal.me/ryanmkay
## License
* Copyright 2016, The Android Open Source Project
*
* 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: app/.gitignore
================================================
/build
================================================
FILE: app/build.gradle
================================================
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion build_versions.target_sdk
defaultConfig {
applicationId "com.wiseassblog.spacenotes"
minSdkVersion build_versions.min_sdk
targetSdkVersion build_versions.target_sdk
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildTypes {
debug {
testCoverageEnabled = true
minifyEnabled false
}
release {
testCoverageEnabled = false
minifyEnabled true
}
}
testOptions.unitTests.all {
useJUnitPlatform()
testLogging {
events 'passed', 'skipped', 'failed', 'standardOut', 'standardError'
}
}
}
dependencies {
implementation project(":domain")
implementation project(":data")
//Dependencies
implementation deps.android.constraint_layout
implementation deps.android.lifecycle_extensions
implementation deps.android.ktx_fragment
implementation deps.android.fragment
implementation deps.android.appcompat
implementation deps.android.recyclerview
implementation deps.android.design
implementation deps.room.runtime
implementation deps.firebase.auth
implementation deps.firebase.firestore
implementation deps.play_services.auth
implementation deps.kotlin.kotlin_jre
implementation deps.kotlin.coroutines_core
implementation deps.kotlin.coroutines_android
kapt deps.room.compiler
testImplementation deps.test.junit
testRuntimeOnly deps.test.jupiter_engine
testRuntimeOnly deps.test.vintage_engine
testImplementation deps.test.mockk
testImplementation deps.test.kotlin_junit
}
apply plugin: 'com.google.gms.google-services'
================================================
FILE: app/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: app/src/main/AndroidManifest.xml
================================================
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/SpaceNotes.kt
================================================
package com.wiseassblog.spacenotes
import android.app.Application
class SpaceNotes: Application() {
override fun onCreate() {
super.onCreate()
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/common/AndroidExt.kt
================================================
package com.wiseassblog.spacenotes.common
import android.app.Activity
import android.content.Intent
import android.text.Editable
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import com.wiseassblog.spacenotes.notedetail.NoteDetailActivity
import com.wiseassblog.spacenotes.notedetail.NoteDetailView
import com.wiseassblog.spacenotes.notelist.NoteListActivity
import java.text.SimpleDateFormat
import java.util.*
internal fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this)
internal fun Activity.attachFragment(manager: FragmentManager, containerId: Int, view: Fragment, tag: String) {
manager.beginTransaction()
.replace(containerId, view, tag)
.commitNowAllowingStateLoss()
}
internal fun Fragment.getCalendarTime(): String {
val cal = Calendar.getInstance(TimeZone.getDefault())
val format = SimpleDateFormat("d MMM yyyy HH:mm:ss Z")
format.timeZone = cal.timeZone
return format.format(cal.time)
}
internal fun Fragment.makeToast(value: String) {
Toast.makeText(activity, value, Toast.LENGTH_SHORT).show()
}
internal fun Fragment.restartCurrentFeature() {
val i: Intent
when (this) {
is NoteDetailView -> {
i = Intent(this.activity, NoteDetailActivity::class.java)
}
//To Be Added
else -> {
i = Intent(this.activity, NoteListActivity::class.java)
}
}
this.activity?.finish()
startActivity(i)
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/common/BaseLogic.kt
================================================
package com.wiseassblog.spacenotes.common
import com.wiseassblog.domain.DispatcherProvider
import kotlinx.coroutines.Job
/**
* Why use a base class? To both share implementation (properties and functions), and enforce a contract (interface) for all listener classes
*/
abstract class BaseLogic(val dispatcher: DispatcherProvider) {
protected lateinit var jobTracker: Job
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/common/Constants.kt
================================================
package com.wiseassblog.spacenotes.common
internal const val MESSAGE_DELETE_SUCCESSFUL = "Note successfully deleted."
internal const val MESSAGE_DELETE = "DELETE"
internal const val MESSAGE_DELETE_CONFIRMATION = "Delete note permanently?"
internal const val MESSAGE_GENERIC_ERROR = "An error has occured."
internal const val MESSAGE_LOGIN = "Log in to use public mode."
internal const val BOOLEAN_EXTRA_IS_PRIVATE = "BOOLEAN_EXTRA_IS_PRIVATE"
internal const val STRING_EXTRA_NOTE_ID = "STRING_EXTRA_NOTE_ID"
internal const val MODE_PRIVATE = "Private Notes"
internal const val MODE_PUBLIC = "Public Notes"
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/common/Navigation.kt
================================================
package com.wiseassblog.spacenotes.common
import android.app.Activity
import android.content.Intent
import com.wiseassblog.spacenotes.login.LoginActivity
import com.wiseassblog.spacenotes.notedetail.NoteDetailActivity
import com.wiseassblog.spacenotes.notelist.NoteListActivity
internal fun startListFeature(activity: Activity?) {
activity?.startActivity(
Intent(
activity,
NoteListActivity::class.java
)
).also { activity?.finish() }
}
internal fun startNoteDetailFeatureWithExtras(activity: Activity?, noteId: String, isPrivate: Boolean) {
val i = Intent(activity, NoteDetailActivity::class.java)
i.putExtra(STRING_EXTRA_NOTE_ID, noteId)
i.putExtra(BOOLEAN_EXTRA_IS_PRIVATE, isPrivate)
activity?.startActivity(i)
}
internal fun startLoginFeature(activity: Activity?) {
val i = Intent(activity, LoginActivity::class.java)
activity?.startActivity(i)
.also { activity?.finish() }
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/login/ILoginContract.kt
================================================
package com.wiseassblog.spacenotes.login
import androidx.lifecycle.Observer
interface ILoginContract {
interface View {
fun setLoginStatus(text: String)
fun setAuthButton(text: String)
fun showLoopAnimation()
fun setStatusDrawable(imageURL: String)
fun startSignInFlow()
fun setObserver(observer: Observer>)
fun startListFeature()
}
interface Logic {
fun event(event: LoginEvent)
}
}
internal const val SIGN_OUT = "SIGN OUT"
internal const val SIGN_IN = "SIGN IN"
internal const val SIGNED_IN = "Signed In"
internal const val SIGNED_OUT = "Signed Out"
internal const val ERROR_NETWORK_UNAVAILABLE = "Network Unavailable"
internal const val ERROR_AUTH = "An Error Has Occured"
internal const val RETRY = "RETRY"
internal const val ANTENNA_EMPTY = "antenna_empty"
internal const val ANTENNA_FULL = "antenna_full"
/**
* This value is just a constant to denote our sign in request; It can be any int.
* Would have been great if that was explained in the docs, I assumed at first that it had to
* be a specific value.
*/
internal const val RC_SIGN_IN = 1337
sealed class LoginEvent {
object OnAuthButtonClick : LoginEvent()
object OnBackClick : LoginEvent()
object OnStart : LoginEvent()
data class OnGoogleSignInResult(val result: LoginResult) : LoginEvent()
object OnDestroy : LoginEvent()
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/login/LoginActivity.kt
================================================
package com.wiseassblog.spacenotes.login
import android.content.Intent
import android.graphics.drawable.AnimationDrawable
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.wiseassblog.spacenotes.R
import com.wiseassblog.spacenotes.login.buildlogic.LoginInjector
import kotlinx.android.synthetic.main.activity_login.*
/**
* Q: Why did I decide to use an Activity as the View in this feature?
* A: Since I want to be able to use the GoogleSignIn API, there is necessary tight coupling
* with Activity in this feature. Further, since this feature is quite simple to begin with,
* I didn't mind breaking SoC a little bit in exchange for the GoogleSignIn functionality.
*
*/
class LoginActivity : AppCompatActivity(), ILoginContract.View {
override fun setObserver(observer: Observer>) = event.observeForever(observer)
override fun startListFeature() = com.wiseassblog.spacenotes.common.startListFeature(this)
val event = MutableLiveData>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
//Note: I cal setObserver within the LoginInjector function
ViewModelProviders.of(this)
.get(LoginInjector::class.java)
.buildLoginLogic(this)
btn_auth_attempt.setOnClickListener { event.value = LoginEvent.OnAuthButtonClick }
imb_toolbar_back.setOnClickListener { event.value = LoginEvent.OnBackClick }
}
override fun onResume() {
super.onResume()
event.value = LoginEvent.OnStart
}
override fun setLoginStatus(text: String) {
lbl_login_status_display.text = text
}
override fun setAuthButton(text: String) {
btn_auth_attempt.text = text
}
override fun showLoopAnimation() {
imv_antenna_animation.setImageResource(
resources.getIdentifier("antenna_loop_fast", "drawable", this.packageName)
)
val satelliteLoop = imv_antenna_animation.drawable as AnimationDrawable
satelliteLoop.start()
}
override fun setStatusDrawable(imageURL: String) {
imv_antenna_animation.setImageResource(
resources.getIdentifier(imageURL, "drawable", this.packageName)
)
}
override fun startSignInFlow() {
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(getString(R.string.default_web_client_id))
.build()
val googleSignInClient = GoogleSignIn.getClient(this, gso)
val signInIntent = googleSignInClient.signInIntent
startActivityForResult(signInIntent, RC_SIGN_IN)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == RC_SIGN_IN) {
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
try {
val account: GoogleSignInAccount? = task.getResult(ApiException::class.java)
event.value = LoginEvent.OnGoogleSignInResult(
LoginResult(
requestCode,
account
)
)
} catch (exception: Exception) {
event.value = LoginEvent.OnGoogleSignInResult(
LoginResult(
0,
null
)
)
}
}
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/login/LoginLogic.kt
================================================
package com.wiseassblog.spacenotes.login
import androidx.lifecycle.Observer
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.spacenotes.common.BaseLogic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class LoginLogic(dispatcher: DispatcherProvider,
val userLocator: UserServiceLocator,
val view: ILoginContract.View,
val authSource: AuthSource) : BaseLogic(dispatcher), CoroutineScope, Observer> {
init {
jobTracker = Job()
}
override val coroutineContext: CoroutineContext
get() = dispatcher.provideUIContext() + jobTracker
override fun onChanged(event: LoginEvent) {
when (event) {
is LoginEvent.OnStart -> onStart()
is LoginEvent.OnDestroy -> jobTracker.cancel()
is LoginEvent.OnBackClick -> onBackClick()
is LoginEvent.OnAuthButtonClick -> onAuthButtonClick()
is LoginEvent.OnGoogleSignInResult -> onSignInResult(event.result)
}
}
private fun onSignInResult(result: LoginResult) = launch {
if (result.requestCode == RC_SIGN_IN && result.account != null) {
view.showLoopAnimation()
val createGoogleUserResult = authSource.createGoogleUser(
result.account.idToken!!,
userLocator
)
when (createGoogleUserResult) {
is Result.Value -> onStart()
is Result.Error -> handleError(createGoogleUserResult.error)
}
} else {
renderErrorState(ERROR_AUTH)
}
}
private fun onAuthButtonClick() = launch {
view.showLoopAnimation()
val authResult = authSource.getCurrentUser(userLocator)
when (authResult) {
is Result.Value -> {
if (authResult.value == null) view.startSignInFlow()
else signUserOut()
}
is Result.Error -> handleError(authResult.error)
}
}
private fun handleError(error: Exception) {
when (error) {
is SpaceNotesError.NetworkUnavailableException -> renderErrorState(
ERROR_NETWORK_UNAVAILABLE
)
else -> renderErrorState(ERROR_AUTH)
}
}
private suspend fun signUserOut() {
val signOutResult = authSource.signOutCurrentUser(userLocator)
when (signOutResult) {
is Result.Value -> renderNullUser()
is Result.Error -> renderErrorState(ERROR_AUTH)
}
}
private fun onBackClick() {
view.startListFeature()
}
private fun onStart() = launch {
view.showLoopAnimation()
val authResult = authSource.getCurrentUser(userLocator)
when (authResult) {
is Result.Value -> {
if (authResult.value == null) renderNullUser()
else renderActiveUser()
}
is Result.Error -> handleError(authResult.error)
}
}
private fun renderActiveUser() {
view.setStatusDrawable(ANTENNA_FULL)
view.setAuthButton(SIGN_OUT)
view.setLoginStatus(SIGNED_IN)
}
private fun renderNullUser() {
view.setStatusDrawable(ANTENNA_EMPTY)
view.setAuthButton(SIGN_IN)
view.setLoginStatus(SIGNED_OUT)
}
private fun renderErrorState(message: String) {
view.setStatusDrawable(ANTENNA_EMPTY)
view.setAuthButton(RETRY)
view.setLoginStatus(message)
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/login/LoginResult.kt
================================================
package com.wiseassblog.spacenotes.login
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.tasks.Task
/**
* Wrapper class for data recieved in LoginActivity's onActivityResult()
* function
*/
data class LoginResult(val requestCode: Int, val account: GoogleSignInAccount?)
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/login/buildlogic/LoginInjector.kt
================================================
package com.wiseassblog.spacenotes.login.buildlogic
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.google.firebase.FirebaseApp
import com.wiseassblog.data.auth.FirebaseAuthRepositoryImpl
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.domain.repository.IAuthRepository
import com.wiseassblog.spacenotes.login.LoginActivity
import com.wiseassblog.spacenotes.login.LoginLogic
class LoginInjector(application: Application) : AndroidViewModel(application) {
init {
FirebaseApp.initializeApp(application)
}
//For user management
private val auth: IAuthRepository by lazy {
//by using lazy, I don't load this resource until I need it
FirebaseAuthRepositoryImpl()
}
fun buildLoginLogic(view: LoginActivity): LoginLogic = LoginLogic(
DispatcherProvider,
UserServiceLocator(auth),
view,
AuthSource()
).also { view.setObserver(it) }
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notedetail/INoteDetailContract.kt
================================================
package com.wiseassblog.spacenotes.notedetail
import androidx.lifecycle.Observer
import com.wiseassblog.domain.domainmodel.Note
/**
* Created by R_KAY on 10/8/2017.
*/
interface INoteDetailContract {
interface View {
fun setBackgroundImage(imageUrl: String)
fun setDateLabel(date: String)
fun setNoteBody(content: String)
fun setObserver(observer: Observer)
fun hideBackButton()
fun getNoteBody(): String
fun getTime(): String
fun restartFeature()
fun showMessage(message: String)
fun showConfirmDeleteSnackbar()
fun startListFeature()
}
interface ViewModel {
fun setIsPrivateMode(isPrivateMode: Boolean)
fun getIsPrivateMode(): Boolean
fun setNoteState(note: Note)
fun getNoteState(): Note?
fun setId(id: String)
fun getId(): String?
}
}
sealed class NoteDetailEvent {
object OnDoneClick : NoteDetailEvent()
object OnDeleteClick : NoteDetailEvent()
object OnDeleteConfirmed : NoteDetailEvent()
object OnBackClick : NoteDetailEvent()
object OnStart : NoteDetailEvent()
object OnBind : NoteDetailEvent()
object OnDestroy : NoteDetailEvent()
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notedetail/NoteDetailActivity.kt
================================================
package com.wiseassblog.spacenotes.notedetail
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.wiseassblog.spacenotes.R
import com.wiseassblog.spacenotes.common.BOOLEAN_EXTRA_IS_PRIVATE
import com.wiseassblog.spacenotes.common.STRING_EXTRA_NOTE_ID
import com.wiseassblog.spacenotes.common.attachFragment
import com.wiseassblog.spacenotes.notedetail.buildlogic.NoteDetailInjector
import com.wiseassblog.spacenotes.notelist.NoteListActivity
private const val VIEW = "NOTE_DETAIL"
class NoteDetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_note_detail)
//Elvis Operator val i:Intent = if intent is null,
// assign i to Intent(this, NoteListActivity::class.java)
val i: Intent = intent ?: Intent(this, NoteListActivity::class.java)
//if intent is null, then it's time to gtfo
if (intent == null) {
Toast.makeText(this, "Application Restarted.", Toast.LENGTH_SHORT).show()
startActivity(i)
}
val noteId = i.getStringExtra(STRING_EXTRA_NOTE_ID)
val isPrivate = i.getBooleanExtra(BOOLEAN_EXTRA_IS_PRIVATE, true)
val view = this.supportFragmentManager.findFragmentByTag(VIEW) as NoteDetailView?
?: NoteDetailView.newInstance()
attachFragment(supportFragmentManager, R.id.root_activity_detail, view, VIEW)
NoteDetailInjector(application)
.buildNoteDetailLogic(view as NoteDetailView, noteId, isPrivate)
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notedetail/NoteDetailLogic.kt
================================================
package com.wiseassblog.spacenotes.notedetail
import androidx.lifecycle.Observer
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.User
import com.wiseassblog.domain.interactor.AnonymousNoteSource
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.domain.interactor.PublicNoteSource
import com.wiseassblog.domain.interactor.RegisteredNoteSource
import com.wiseassblog.spacenotes.common.BaseLogic
import com.wiseassblog.spacenotes.common.MESSAGE_DELETE_SUCCESSFUL
import com.wiseassblog.spacenotes.common.MESSAGE_GENERIC_ERROR
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class NoteDetailLogic(dispatcher: DispatcherProvider,
val noteLocator: NoteServiceLocator,
val userLocator: UserServiceLocator,
val vModel: INoteDetailContract.ViewModel,
val view: INoteDetailContract.View,
val anonymousNoteSource: AnonymousNoteSource,
val registeredNoteSource: RegisteredNoteSource,
val publicNoteSource: PublicNoteSource,
val authSource: AuthSource,
id: String,
isPrivate: Boolean)
: BaseLogic(dispatcher), CoroutineScope, Observer {
init {
vModel.setId(id)
vModel.setIsPrivateMode(isPrivate)
jobTracker = Job()
}
fun clear() {
jobTracker.cancel()
}
override val coroutineContext: CoroutineContext
get() = dispatcher.provideUIContext() + jobTracker
override fun onChanged(event: NoteDetailEvent) {
when (event) {
is NoteDetailEvent.OnDoneClick -> onDoneClick()
is NoteDetailEvent.OnDeleteClick -> onDeleteClick()
is NoteDetailEvent.OnBackClick -> onBackClick()
is NoteDetailEvent.OnDeleteConfirmed -> onDeleteConfirmed()
is NoteDetailEvent.OnStart -> onStart()
is NoteDetailEvent.OnBind -> bind()
is NoteDetailEvent.OnDestroy -> clear()
}
}
fun onDoneClick() = launch {
val userResult = authSource.getCurrentUser(userLocator)
when (userResult) {
is Result.Value -> {
//if null, user is anonymous
if (userResult.value == null) prepareAnonymousRepoUpdate()
else if (vModel.getIsPrivateMode()) prepareRegisteredRepoUpdate()
else preparePublicRepoUpdate()
}
}
}
private suspend fun prepareAnonymousRepoUpdate() {
val updatedNote = vModel.getNoteState()!!.copy(contents = view.getNoteBody())
val result = anonymousNoteSource.updateNote(updatedNote, noteLocator)
when (result) {
is Result.Value -> view.startListFeature()
is Result.Error -> view.showMessage(result.error.toString())
}
}
suspend fun prepareRegisteredRepoUpdate() {
val updatedNote = vModel.getNoteState()!!.copy(contents = view.getNoteBody())
val result = registeredNoteSource.updateNote(updatedNote, noteLocator)
when (result) {
is Result.Value -> view.startListFeature()
is Result.Error -> view.showMessage(result.error.toString())
}
}
suspend fun preparePublicRepoUpdate() {
val updatedNote = vModel.getNoteState()!!
.copy(contents = view.getNoteBody())
val result = publicNoteSource.updateNote(updatedNote, noteLocator)
when (result) {
is Result.Value -> view.startListFeature()
is Result.Error -> view.showMessage(result.error.toString())
}
}
fun bind() = launch {
val userResult = authSource.getCurrentUser(userLocator)
when (userResult) {
is Result.Value -> {
val id = vModel.getId()
if (id == "" || id == null) createNewNote(userResult.value)
else getNoteFromSource(id, userResult.value)
}
is Result.Error -> view.showMessage(userResult.error.toString())
}
}
fun createNewNote(user: User?) {
vModel.setNoteState(
Note(
view.getTime(),
"",
0,
"satellite_beam",
user
)
)
//only save or delete with new note
view.hideBackButton()
onStart()
}
fun getNoteFromSource(id: String, user: User?) = launch {
val noteResult: Result
//private anonymous
if (user == null) noteResult = anonymousNoteSource.getNoteById(id, noteLocator)
//private registered
else if (vModel.getIsPrivateMode()) noteResult = registeredNoteSource.getNoteById(id, noteLocator)
//public registered
else noteResult = publicNoteSource.getNoteById(id, noteLocator)
when (noteResult) {
is Result.Value -> {
vModel.setNoteState(noteResult.value!!)
onStart()
}
is Result.Error -> {
val message = noteResult.error.message ?: "An error has occured."
view.showMessage(message)
}
}
}
fun onStart() {
val state = vModel.getNoteState()
//LiveData requires null checks due to nullable return types
if (state != null) {
renderView(state)
} else {
view.showMessage(MESSAGE_GENERIC_ERROR)
view.startListFeature()
}
}
private fun renderView(state: Note) {
view.setBackgroundImage(state.imageUrl)
view.setDateLabel(state.creationDate)
view.setNoteBody(state.contents)
}
fun onBackClick() {
view.startListFeature()
}
fun onDeleteClick() {
view.showConfirmDeleteSnackbar()
}
fun onDeleteConfirmed() = launch {
val currentNote = vModel.getNoteState()
//if VM data is null, we're in a bad spot
if (currentNote == null) {
view.showMessage(MESSAGE_GENERIC_ERROR)
view.restartFeature()
} else {
val userResult = authSource.getCurrentUser(userLocator)
when (userResult) {
is Result.Value -> {
if (userResult.value == null) prepareAnonymousRepoDelete(currentNote)
else if (vModel.getIsPrivateMode()) prepareRegisteredRepoDelete(currentNote)
else preparePublicRepoDelete(currentNote)
}
is Result.Error -> view.showMessage(userResult.error.toString())
}
}
}
private fun preparePublicRepoDelete(note: Note) = launch {
val result = publicNoteSource.deleteNote(note, noteLocator)
when (result) {
is Result.Value -> {
view.showMessage(MESSAGE_DELETE_SUCCESSFUL)
view.startListFeature()
}
is Result.Error -> view.showMessage(result.error.toString())
}
}
private fun prepareRegisteredRepoDelete(note: Note) = launch {
val result = registeredNoteSource.deleteNote(note, noteLocator)
when (result) {
is Result.Value -> {
view.showMessage(MESSAGE_DELETE_SUCCESSFUL)
view.startListFeature()
}
is Result.Error -> view.showMessage(result.error.toString())
}
}
private fun prepareAnonymousRepoDelete(note: Note) = launch {
val result = anonymousNoteSource.deleteNote(note, noteLocator)
when (result) {
is Result.Value -> {
view.showMessage(MESSAGE_DELETE_SUCCESSFUL)
view.startListFeature()
}
is Result.Error -> view.showMessage(result.error.toString())
}
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notedetail/NoteDetailView.kt
================================================
package com.wiseassblog.spacenotes.notedetail
import android.graphics.drawable.AnimationDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar
import com.wiseassblog.spacenotes.R
import com.wiseassblog.spacenotes.R.id.*
import com.wiseassblog.spacenotes.common.*
import com.wiseassblog.spacenotes.notedetail.buildlogic.NoteDetailInjector
import com.wiseassblog.spacenotes.notelist.NoteListEvent
import kotlinx.android.synthetic.main.fragment_note_detail.*
class NoteDetailView : Fragment(), INoteDetailContract.View {
val event = MutableLiveData()
override fun setObserver(observer: Observer) = event.observeForever(observer)
override fun startListFeature() = com.wiseassblog.spacenotes.common.startListFeature(this.activity)
override fun hideBackButton() {
imb_toolbar_back.visibility = View.INVISIBLE
imb_toolbar_back.isEnabled = false
}
override fun getTime(): String = getCalendarTime()
override fun showConfirmDeleteSnackbar() {
if (activity != null) {
Snackbar.make(frag_note_detail, MESSAGE_DELETE_CONFIRMATION, Snackbar.LENGTH_LONG)
.setAction(MESSAGE_DELETE) { event.value = NoteDetailEvent.OnDeleteConfirmed }
.show()
}
}
override fun showMessage(message: String) = makeToast(message)
override fun restartFeature() = restartCurrentFeature()
override fun getNoteBody(): String {
return edt_note_detail_text.text.toString()
}
override fun setBackgroundImage(imageUrl: String) {
imv_note_detail_satellite.setImageResource(
resources.getIdentifier(imageUrl, "drawable", context?.packageName)
)
val satelliteLoop = imv_note_detail_satellite.drawable as AnimationDrawable
satelliteLoop.start()
}
override fun setDateLabel(date: String) {
lbl_note_detail_date.text = date
}
override fun setNoteBody(content: String) {
edt_note_detail_text.text = content.toEditable()
}
override fun onStart() {
super.onStart()
event.value = NoteDetailEvent.OnBind
}
override fun onDestroy() {
event.value = NoteDetailEvent.OnDestroy
super.onDestroy()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_note_detail, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
imb_toolbar_done.setOnClickListener { event.value = NoteDetailEvent.OnDoneClick }
imb_toolbar_back.setOnClickListener { event.value = NoteDetailEvent.OnBackClick }
imb_toolbar_delete.setOnClickListener { event.value = NoteDetailEvent.OnDeleteClick }
val spaceLoop = frag_note_detail.background as AnimationDrawable
spaceLoop.setEnterFadeDuration(1000)
spaceLoop.setExitFadeDuration(1000)
spaceLoop.start()
super.onViewCreated(view, savedInstanceState)
}
companion object {
@JvmStatic
fun newInstance() =
NoteDetailView()
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notedetail/NoteDetailViewModel.kt
================================================
package com.wiseassblog.spacenotes.notedetail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.spacenotes.notedetail.INoteDetailContract
class NoteDetailViewModel(private var displayState: MutableLiveData = MutableLiveData(),
private var id: MutableLiveData = MutableLiveData(),
private var isPrivateMode: MutableLiveData = MutableLiveData()) : ViewModel(),
INoteDetailContract.ViewModel {
override fun getIsPrivateMode(): Boolean {
return isPrivateMode.value!!
}
override fun setIsPrivateMode(isPrivateMode: Boolean) {
this.isPrivateMode.value = isPrivateMode
}
override fun setId(id: String) {
this.id.value = id
}
override fun getId(): String? {
return this.id.value
}
override fun getNoteState(): Note? {
return displayState.value
}
override fun setNoteState(note: Note) {
displayState.value = note
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notedetail/buildlogic/NoteDetailInjector.kt
================================================
package com.wiseassblog.spacenotes.notedetail.buildlogic
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProviders
import com.google.firebase.FirebaseApp
import com.wiseassblog.data.auth.FirebaseAuthRepositoryImpl
import com.wiseassblog.data.note.anonymous.AnonymousNoteDao
import com.wiseassblog.data.note.anonymous.AnonymousNoteDatabase
import com.wiseassblog.data.note.anonymous.RoomLocalAnonymousRepositoryImpl
import com.wiseassblog.data.note.public.FirestoreRemoteNoteImpl
import com.wiseassblog.data.note.registered.*
import com.wiseassblog.data.transaction.RoomRegisteredTransactionDatabase
import com.wiseassblog.data.transaction.RoomTransactionRepositoryImpl
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.interactor.AnonymousNoteSource
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.domain.interactor.PublicNoteSource
import com.wiseassblog.domain.interactor.RegisteredNoteSource
import com.wiseassblog.domain.repository.*
import com.wiseassblog.spacenotes.notedetail.*
/**
*
*/
class NoteDetailInjector(application: Application) : AndroidViewModel(application) {
init {
FirebaseApp.initializeApp(application)
}
private val anonNoteDao: AnonymousNoteDao by lazy {
AnonymousNoteDatabase.getInstance(getApplication()).roomNoteDao()
}
private val regNoteDao: RegisteredNoteDao by lazy {
RegisteredNoteDatabase.getInstance(getApplication()).roomNoteDao()
}
private val transactionDao: RegisteredTransactionDao by lazy {
RoomRegisteredTransactionDatabase.getInstance(getApplication()).roomTransactionDao()
}
//For non-registered user persistence
private val localAnon: ILocalNoteRepository by lazy {
RoomLocalAnonymousRepositoryImpl(anonNoteDao)
}
//For registered user remote persistence (Source of Truth)
private val remotePrivate: IRemoteNoteRepository by lazy {
FirestorePrivateRemoteNoteImpl()
}
//For registered user remote persistence (Source of Truth)
private val remotePublic: IPublicNoteRepository by lazy {
FirestoreRemoteNoteImpl
}
//For registered user local persistience (cache)
private val cacheReg: ILocalNoteRepository by lazy {
RoomLocalCacheImpl(regNoteDao)
}
//For registered user remote persistence (Source of Truth)
private val remoteRepo: IRemoteNoteRepository by lazy {
RegisteredNoteRepositoryImpl(remotePrivate, cacheReg)
}
//For registered user local persistience (cache)
private val transactionReg: ITransactionRepository by lazy {
RoomTransactionRepositoryImpl(transactionDao)
}
//For user management
private val auth: IAuthRepository by lazy {
FirebaseAuthRepositoryImpl()
}
private lateinit var logic: NoteDetailLogic
fun buildNoteDetailLogic(view: NoteDetailView,
id: String,
isPrivate: Boolean): NoteDetailLogic {
logic = NoteDetailLogic(
DispatcherProvider,
NoteServiceLocator(localAnon, remoteRepo, transactionReg, remotePublic),
UserServiceLocator(auth),
ViewModelProviders.of(view)
.get(NoteDetailViewModel::class.java),
view,
AnonymousNoteSource(),
RegisteredNoteSource(),
PublicNoteSource(),
AuthSource(),
id,
isPrivate
)
view.setObserver(logic)
return logic
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notelist/INoteListContract.kt
================================================
package com.wiseassblog.spacenotes.notelist
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.ListAdapter
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.User
/**
* Created by R_KAY on 10/8/2017.
*/
interface INoteListContract {
interface View {
fun setAdapter(adapter: ListAdapter)
fun setPrivateIcon(isPrivate: Boolean)
fun showList()
fun showEmptyState()
fun showErrorState(message:String)
fun showLoadingView()
fun setToolbarTitle(title: String)
fun startLoginFeature()
fun setObserver(observer: Observer>)
fun startNoteDetailFeatureWithExtras(noteId: String, isPrivate: Boolean)
}
interface ViewModel {
fun setAdapterState(result: List)
fun getAdapterState(): List
fun getUserState(): User?
fun setUserState(userResult: User?)
fun getIsPrivateMode(): Boolean
fun setIsPrivateMode(isPrivateMode: Boolean)
}
}
sealed class NoteListEvent {
data class OnNoteItemClick(val position: Int) : NoteListEvent()
object OnNewNoteClick : NoteListEvent()
object OnLoginClick : NoteListEvent()
object OnTogglePublicMode : NoteListEvent()
object OnStart : NoteListEvent()
object OnBind : NoteListEvent()
object OnDestroy : NoteListEvent()
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notelist/NoteDiffUtilCallback.kt
================================================
package com.wiseassblog.spacenotes.notelist
import androidx.recyclerview.widget.DiffUtil
import com.wiseassblog.domain.domainmodel.Note
class NoteDiffUtilCallback : DiffUtil.ItemCallback(){
override fun areItemsTheSame(oldItem: Note, newItem: Note): Boolean {
return oldItem.creationDate == newItem.creationDate
}
override fun areContentsTheSame(oldItem: Note, newItem: Note): Boolean {
return oldItem.creationDate == newItem.creationDate
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notelist/NoteListActivity.kt
================================================
package com.wiseassblog.spacenotes.notelist
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import com.wiseassblog.spacenotes.R
import com.wiseassblog.spacenotes.common.attachFragment
import com.wiseassblog.spacenotes.notelist.buildlogic.NoteListInjector
private const val VIEW = "NOTE_LIST"
class NoteListActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_note_list)
//A container basically just builds things and sets the feature in motion
val view = this.supportFragmentManager.findFragmentByTag(VIEW)
?: NoteListView.newInstance()
attachFragment(supportFragmentManager, R.id.root_activity_list, view, VIEW)
NoteListInjector(application)
.buildNoteListLogic(view as NoteListView)
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notelist/NoteListAdapter.kt
================================================
package com.wiseassblog.spacenotes.notelist
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.spacenotes.R
import kotlinx.android.synthetic.main.item_note.view.*
class NoteListAdapter(var event: MutableLiveData> = MutableLiveData() ) : ListAdapter(NoteDiffUtilCallback()) {
internal fun setObserver(observer: Observer>) = event.observeForever(observer)
override fun onBindViewHolder(holder: NoteListAdapter.NoteViewHolder, position: Int) {
getItem(position).let { note ->
with(holder) {
holder.content.text = note.contents
holder.date.text = note.creationDate
holder.square.setImageResource(R.drawable.gps_icon)
holder.content.text = note.contents
holder.itemView.setOnClickListener {
event.value = NoteListEvent.OnNoteItemClick(position)
}
}
}
holder.apply {
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder {
val inflater = LayoutInflater.from(parent.context)
return NoteViewHolder(
inflater.inflate(R.layout.item_note, parent, false)
)
}
class NoteViewHolder(private val root: View) : RecyclerView.ViewHolder(root) {
var square: ImageView = root.imv_list_item_icon
var dateIcon: ImageView = root.imv_date_and_time
var content: TextView = root.lbl_message
var date: TextView = root.lbl_date_and_time
var loading: ProgressBar = root.pro_item_data
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notelist/NoteListLogic.kt
================================================
package com.wiseassblog.spacenotes.notelist
import androidx.lifecycle.Observer
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.interactor.AnonymousNoteSource
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.domain.interactor.PublicNoteSource
import com.wiseassblog.domain.interactor.RegisteredNoteSource
import com.wiseassblog.spacenotes.common.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class NoteListLogic(dispatcher: DispatcherProvider,
val noteLocator: NoteServiceLocator,
val userLocator: UserServiceLocator,
val vModel: INoteListContract.ViewModel,
var adapter: NoteListAdapter,
val view: INoteListContract.View,
val anonymousNoteSource: AnonymousNoteSource,
val registeredNoteSource: RegisteredNoteSource,
val publicNoteSource: PublicNoteSource,
val authSource: AuthSource)
: BaseLogic(dispatcher), CoroutineScope, Observer> {
override fun onChanged(event: NoteListEvent?) {
when (event) {
is NoteListEvent.OnNoteItemClick -> onNoteItemClick(event.position)
is NoteListEvent.OnNewNoteClick -> onNewNoteClick()
is NoteListEvent.OnLoginClick -> onLoginClick()
is NoteListEvent.OnTogglePublicMode -> onTogglePublicMode()
is NoteListEvent.OnStart -> onStart()
is NoteListEvent.OnBind -> bind()
is NoteListEvent.OnDestroy -> clear()
}
}
init {
//This is directly analogous to CompositeDisposable
jobTracker = Job()
}
//dispatcher.provideUIContext is very analogous to observeOn(Dispatchers.UI)
override val coroutineContext: CoroutineContext
get() = dispatcher.provideUIContext() + jobTracker
private fun onNewNoteClick() = view.startNoteDetailFeatureWithExtras(
"",
vModel.getIsPrivateMode()
)
private fun onStart() {
getListData(vModel.getIsPrivateMode())
}
fun getListData(isPrivateMode: Boolean) = launch {
val dataResult: Result>
when (isPrivateMode) {
true -> dataResult = getPrivateListData()
false -> dataResult = getPublicListData()
}
when (dataResult) {
is Result.Value -> {
vModel.setAdapterState(dataResult.value)
renderView(dataResult.value)
}
is Result.Error -> {
view.showEmptyState()
view.showErrorState(MESSAGE_GENERIC_ERROR)
}
}
}
suspend fun getPublicListData(): Result> {
return if (vModel.getUserState() != null) publicNoteSource.getNotes(noteLocator)
else Result.build { throw SpaceNotesError.LocalIOException }
}
suspend fun getPrivateListData(): Result> {
return if (vModel.getUserState() == null) anonymousNoteSource.getNotes(noteLocator)
else registeredNoteSource.getNotes(noteLocator)
}
fun renderView(list: List) {
view.setPrivateIcon(vModel.getIsPrivateMode())
if (vModel.getIsPrivateMode()) view.setToolbarTitle(MODE_PRIVATE)
else view.setToolbarTitle(MODE_PUBLIC)
if (list.isEmpty()) view.showEmptyState()
else view.showList()
adapter.submitList(list)
}
private fun onTogglePublicMode() {
if (vModel.getUserState() != null) {
if (vModel.getIsPrivateMode()) {
vModel.setIsPrivateMode(false)
getListData(false)
} else {
vModel.setIsPrivateMode(true)
getListData(true)
}
} else {
view.showErrorState(MESSAGE_LOGIN)
}
}
private fun onLoginClick() {
view.startLoginFeature()
}
private fun onNoteItemClick(position: Int) {
val listData = vModel.getAdapterState()
view.startNoteDetailFeatureWithExtras(
listData[position].creationDate, vModel.getIsPrivateMode())
}
fun bind() {
view.setToolbarTitle(MODE_PRIVATE)
view.showLoadingView()
adapter.setObserver(this)
view.setAdapter(adapter)
view.setObserver(this)
launch {
val result = authSource.getCurrentUser(userLocator)
if (result is Result.Value) vModel.setUserState(result.value)
//otherwise defaults to null
}
}
//Single Expression Syntax
fun clear() = jobTracker.cancel()
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notelist/NoteListView.kt
================================================
package com.wiseassblog.spacenotes.notelist
import android.graphics.drawable.AnimationDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.ListAdapter
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.spacenotes.R
import com.wiseassblog.spacenotes.R.id.*
import com.wiseassblog.spacenotes.common.makeToast
import com.wiseassblog.spacenotes.notelist.buildlogic.NoteListInjector
import kotlinx.android.synthetic.main.fragment_note_list.*
class NoteListView : Fragment(), INoteListContract.View {
override fun setPrivateIcon(isPrivate: Boolean) {
//private mode
if (isPrivate) imv_toolbar_private_toggle.setImageResource(R.drawable.design_ic_visibility_off)
//public mode
else imv_toolbar_private_toggle.setImageResource(R.drawable.design_ic_visibility)
}
val event = MutableLiveData>()
//Event listener
override fun setObserver(observer: Observer>) = event.observeForever(observer)
override fun showErrorState(message: String) = this.makeToast(message)
override fun startLoginFeature() = com.wiseassblog.spacenotes.common.startLoginFeature(this.activity)
override fun startNoteDetailFeatureWithExtras(noteId: String, isPrivate: Boolean) = com.wiseassblog.spacenotes.common.startNoteDetailFeatureWithExtras(this.activity, noteId, isPrivate)
override fun setToolbarTitle(title: String) {
lbl_toolbar_title.text = title
}
override fun setAdapter(adapter: ListAdapter) {
rec_list_activity.adapter = adapter
}
override fun showLoadingView() {
rec_list_activity.visibility = View.INVISIBLE
fab_create_new_item.hide()
imv_satellite_animation.visibility = View.VISIBLE
//set loading animation
val satelliteLoop = imv_satellite_animation.drawable as AnimationDrawable
satelliteLoop.start()
}
override fun showEmptyState() {
rec_list_activity.visibility = View.INVISIBLE
fab_create_new_item.show()
imv_satellite_animation.visibility = View.VISIBLE
val satelliteLoop = imv_satellite_animation.drawable as AnimationDrawable
satelliteLoop.start()
}
override fun showList() {
rec_list_activity.visibility = View.VISIBLE
fab_create_new_item.show()
imv_satellite_animation.visibility = View.INVISIBLE
val satelliteLoop = imv_satellite_animation.drawable as AnimationDrawable
satelliteLoop.stop()
}
override fun onStart() {
super.onStart()
event.value = NoteListEvent.OnBind
}
override fun onResume() {
super.onResume()
event.value = NoteListEvent.OnStart
}
override fun onDestroy() {
event.value = NoteListEvent.OnDestroy
super.onDestroy()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_note_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
event.value = NoteListEvent.OnBind
imv_toolbar_auth.setOnClickListener { event.value = NoteListEvent.OnLoginClick }
imv_toolbar_private_toggle.setOnClickListener { event.value = NoteListEvent.OnTogglePublicMode }
fab_create_new_item.setOnClickListener { event.value = NoteListEvent.OnNewNoteClick }
val spaceLoop = imv_space_background.drawable as AnimationDrawable
spaceLoop.setEnterFadeDuration(1000)
spaceLoop.setExitFadeDuration(1000)
spaceLoop.start()
super.onViewCreated(view, savedInstanceState)
}
companion object {
@JvmStatic
fun newInstance(): Fragment = NoteListView()
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notelist/NoteListViewModel.kt
================================================
package com.wiseassblog.spacenotes.notelist
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.User
/**
* isPrivateMode refers to whether the User wants to post to and read from a shared repo, or they
* would like to store their note in private storage.
*/
class NoteListViewModel(private var adapterData: MutableLiveData> = MutableLiveData(),
private var user: MutableLiveData = MutableLiveData(),
private var isPrivateMode: MutableLiveData = MutableLiveData()) : ViewModel(),
INoteListContract.ViewModel {
init {
isPrivateMode.value = true
}
override fun getIsPrivateMode(): Boolean {
return isPrivateMode.value!!
}
override fun setIsPrivateMode(isPrivateMode: Boolean) {
this.isPrivateMode.value = isPrivateMode
}
override fun setAdapterState(result: List) {
adapterData.value = result
}
override fun setUserState(userResult: User?) {
user.value = userResult
}
override fun getUserState(): User? {
return user.value
}
override fun getAdapterState(): List {
//return current display state or empty string if value is null
//see "Elvis Operator"
return adapterData.value ?: emptyList()
}
}
================================================
FILE: app/src/main/java/com/wiseassblog/spacenotes/notelist/buildlogic/NoteListInjector.kt
================================================
package com.wiseassblog.spacenotes.notelist.buildlogic
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProviders
import com.google.firebase.FirebaseApp
import com.wiseassblog.data.auth.FirebaseAuthRepositoryImpl
import com.wiseassblog.data.note.anonymous.AnonymousNoteDao
import com.wiseassblog.data.note.anonymous.AnonymousNoteDatabase
import com.wiseassblog.data.note.anonymous.RoomLocalAnonymousRepositoryImpl
import com.wiseassblog.data.note.public.FirestoreRemoteNoteImpl
import com.wiseassblog.data.note.registered.*
import com.wiseassblog.data.transaction.RoomRegisteredTransactionDatabase
import com.wiseassblog.data.transaction.RoomTransactionRepositoryImpl
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.interactor.AnonymousNoteSource
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.domain.interactor.PublicNoteSource
import com.wiseassblog.domain.interactor.RegisteredNoteSource
import com.wiseassblog.domain.repository.*
import com.wiseassblog.spacenotes.notelist.NoteListAdapter
import com.wiseassblog.spacenotes.notelist.NoteListLogic
import com.wiseassblog.spacenotes.notelist.NoteListView
import com.wiseassblog.spacenotes.notelist.NoteListViewModel
class NoteListInjector(application: Application) : AndroidViewModel(application) {
init {
FirebaseApp.initializeApp(application)
}
private val anonNoteDao: AnonymousNoteDao by lazy {
AnonymousNoteDatabase.getInstance(application).roomNoteDao()
}
private val regNoteDao: RegisteredNoteDao by lazy {
RegisteredNoteDatabase.getInstance(application).roomNoteDao()
}
private val transactionDao: RegisteredTransactionDao by lazy {
RoomRegisteredTransactionDatabase.getInstance(application).roomTransactionDao()
}
//For non-registered user persistence
private val localAnon: ILocalNoteRepository by lazy {
RoomLocalAnonymousRepositoryImpl(anonNoteDao)
}
//For registered user remote persistence (Source of Truth)
private val remotePrivate: IRemoteNoteRepository by lazy {
FirestorePrivateRemoteNoteImpl()
}
//For registered user remote persistence (Source of Truth)
private val remotePublicRepo: IPublicNoteRepository by lazy {
FirestoreRemoteNoteImpl
}
//For registered user local persistience (cache)
private val cacheReg: ILocalNoteRepository by lazy {
RoomLocalCacheImpl(regNoteDao)
}
//For registered user remote persistence (Source of Truth)
private val remotePrivateRepo: IRemoteNoteRepository by lazy {
RegisteredNoteRepositoryImpl(remotePrivate, cacheReg)
}
//For registered user local persistience (cache)
private val transactionReg: ITransactionRepository by lazy {
RoomTransactionRepositoryImpl(transactionDao)
}
//For user management
private val auth: IAuthRepository by lazy {
FirebaseAuthRepositoryImpl()
}
private lateinit var logic: NoteListLogic
fun buildNoteListLogic(view: NoteListView): NoteListLogic {
logic = NoteListLogic(
DispatcherProvider,
NoteServiceLocator(localAnon, remotePrivateRepo, transactionReg, remotePublicRepo),
UserServiceLocator(auth),
ViewModelProviders.of(view)
.get(NoteListViewModel::class.java),
NoteListAdapter(),
view,
AnonymousNoteSource(),
RegisteredNoteSource(),
PublicNoteSource(),
AuthSource()
)
view.setObserver(logic)
return logic
}
}
================================================
FILE: app/src/main/res/drawable/antenna_loop.xml
================================================
================================================
FILE: app/src/main/res/drawable/antenna_loop_fast.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_access_time_black_24dp.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_arrow_back_black_24dp.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_baseline_add_24px.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_baseline_event_24px.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_delete_forever_black_24dp.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_done_black_24dp.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_launcher_background.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_visibility_off_black_24dp.xml
================================================
================================================
FILE: app/src/main/res/drawable/ic_vpn_key_black_24dp.xml
================================================
================================================
FILE: app/src/main/res/drawable/satellite_beam.xml
================================================
================================================
FILE: app/src/main/res/drawable/space_loop.xml
================================================
================================================
FILE: app/src/main/res/drawable-v24/ic_launcher_foreground.xml
================================================
================================================
FILE: app/src/main/res/layout/activity_login.xml
================================================
================================================
FILE: app/src/main/res/layout/activity_note_detail.xml
================================================
================================================
FILE: app/src/main/res/layout/activity_note_list.xml
================================================
================================================
FILE: app/src/main/res/layout/activity_user_auth.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_note_detail.xml
================================================
================================================
FILE: app/src/main/res/layout/fragment_note_list.xml
================================================
================================================
FILE: app/src/main/res/layout/item_note.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
================================================
================================================
FILE: app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
================================================
================================================
FILE: app/src/main/res/values/colors.xml
================================================
#121212#000000#80D8FF#121212#52000000#52FFFFFF#FF0000#0000FF#00FF00#FFEB3B
================================================
FILE: app/src/main/res/values/strings.xml
================================================
SpaceNotes
================================================
FILE: app/src/main/res/values/styles.xml
================================================
================================================
FILE: app/src/main/res/values/view_styles.xml
================================================
================================================
FILE: app/src/test/java/com/wiseassblog/spacenotes/LoginLogicTest.kt
================================================
package com.wiseassblog.spacenotes
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.User
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.spacenotes.login.*
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class LoginLogicTest {
private val dispatcher: DispatcherProvider = mockk()
private val userLocator: UserServiceLocator = mockk()
private val view: ILoginContract.View = mockk(relaxed = true)
private val auth: AuthSource = mockk()
private val testAccount: GoogleSignInAccount = mockk()
private lateinit var logic: LoginLogic
val testIdToken: String = "8675309"
fun getUser(uid: String = "8675309",
name: String = "Ajahn Chah",
profilePicUrl: String = ""
) = User(uid,
name,
profilePicUrl)
@Before
fun clear() {
clearAllMocks()
logic = LoginLogic(dispatcher, userLocator, view, auth)
}
/**
* In onstart, we give a channel to the firebaseauth backend which it can use to push the latest
* user state to listener.
* the ui appropriately.
* a. User is retrieved successfully
* b. User is null
* c. Exception: no network connectivity
*
* a:
* 1. Check Network status: available
* 2. Ask auth source for current user: User
* 3. Start antenna loop
* 4. set login status: "Signed In"
* 5. set login button: "SIGN OUT"
*
*/
@Test
fun `On Start retrieve User`() = runBlocking {
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
logic.onChanged(LoginEvent.OnStart)
coVerify { auth.getCurrentUser(userLocator) }
verify { view.setLoginStatus(SIGNED_IN) }
verify { view.showLoopAnimation() }
verify { view.setStatusDrawable(ANTENNA_FULL) }
verify { view.setAuthButton(SIGN_OUT) }
}
/**
*b:
* 1. Check Network status: available
* 2. Ask auth source for current user: null
* 3. Set animation to antenna_full
* 4. set login status: "Signed Out"
* 5. set login button: "SIGN IN"
*/
@Test
fun `On Start retrieve null`() = runBlocking {
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { null }
logic.onChanged(LoginEvent.OnStart)
coVerify { auth.getCurrentUser(userLocator) }
verify { view.setLoginStatus(SIGNED_OUT) }
verify { view.showLoopAnimation() }
verify { view.setStatusDrawable(ANTENNA_EMPTY) }
verify { view.setAuthButton(SIGN_IN) }
}
/**
*c:
* 1. Check network status: unavailable
* 2. set animatin to drawable antenna_empty:
* 3. set login status: "Network Unavailable"
* 4. set login button: "RETRY"
*/
@Test
fun `On Start retrieve network error`() = runBlocking {
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { throw SpaceNotesError.NetworkUnavailableException }
logic.onChanged(LoginEvent.OnStart)
coVerify { auth.getCurrentUser(userLocator) }
verify { view.setLoginStatus(ERROR_NETWORK_UNAVAILABLE) }
verify { view.showLoopAnimation() }
verify { view.setStatusDrawable(ANTENNA_EMPTY) }
verify { view.setAuthButton(RETRY) }
}
/**
*In OnAuthButtonClick, the user wishes to sign in to the application. Instruct View to
* create and launch GoogleSignInClient for result, and fire the intent
* a. User is currently signed out
* b. User is currently signed in
* c. network is unavailable
*
* a.
* 1. Check network status: available
* 2. User result: null
* 3. start sign in flow
*/
@Test
fun `On Auth Button Click signed out`() = runBlocking {
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { null }
logic.onChanged(LoginEvent.OnAuthButtonClick)
coVerify { auth.getCurrentUser(userLocator) }
verify { view.showLoopAnimation() }
verify { view.startSignInFlow() }
}
/**
* b.
* 1. Check network status: available
* 2. User result: User
* 3. tell auth to sign user out
* 4. render user signed out view
*/
@Test
fun `On Auth Button Click signed in`() = runBlocking {
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
coEvery {
auth.signOutCurrentUser(userLocator)
} returns Result.build { Unit }
logic.onChanged(LoginEvent.OnAuthButtonClick)
verify { view.showLoopAnimation() }
coVerify { auth.getCurrentUser(userLocator) }
coVerify { auth.signOutCurrentUser(userLocator) }
verify { view.setLoginStatus(SIGNED_OUT) }
verify { view.setStatusDrawable(ANTENNA_EMPTY) }
verify { view.setAuthButton(SIGN_IN) }
}
/**
* c.
* 1. Check network status: unavailable
* 2. render error view
*/
@Test
fun `On Auth Button Click network unavailable`() = runBlocking {
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { throw SpaceNotesError.NetworkUnavailableException }
logic.onChanged(LoginEvent.OnAuthButtonClick)
verify { view.showLoopAnimation() }
coVerify { auth.getCurrentUser(userLocator) }
verify { view.setLoginStatus(ERROR_NETWORK_UNAVAILABLE) }
verify { view.setStatusDrawable(ANTENNA_EMPTY) }
verify { view.setAuthButton(RETRY) }
}
@Test
fun `On Back Click`() = runBlocking {
logic.onChanged(LoginEvent.OnBackClick)
verify { view.startListFeature() }
}
/**
* When the user wishes to create Sign In or create a new account, the result of this
* action will pop up in onActivityResult(), which is called prior to onResume(). Since
* we're already preferring pragmatism over separation of concerns in this feature due to tight
* coupling with Activities, I've chosen to attempt to retrieve the user account from in the
* activity. After that, it's up to the Logic class and backend to figure things out.
*
* a. GoogleSignInAccount succesfully retrieved
* b. GoogleSignInAcccount was null, or the task threw an exception
*
* 1. Pass LoginResult to Logic:
* 2. Check request code. If RC_SIGN_IN, we know that the result has to do with
* Signing In.
* 3. Pass token to backend
* 4. Attempt to await response for auth sign in result. This may timeout.
* 5. Either way ask firebase for the current user
*
*/
@Test
fun `On Sign In Result RC_SIGN_IN account idToken acquired`() = runBlocking {
val loginResult = LoginResult(RC_SIGN_IN, testAccount)
every {
testAccount.idToken
} returns testIdToken
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
coEvery {
auth.createGoogleUser(testIdToken, userLocator)
} returns Result.build { Unit }
logic.onChanged(LoginEvent.OnGoogleSignInResult(loginResult))
coVerify { auth.createGoogleUser(testIdToken, userLocator) }
coVerify { auth.getCurrentUser(userLocator) }
verify { view.setLoginStatus(SIGNED_IN) }
verify { view.showLoopAnimation() }
verify { view.setStatusDrawable(ANTENNA_FULL) }
verify { view.setAuthButton(SIGN_OUT) }
}
/**
* b.
*/
@Test
fun `On Sign In Result RC_SIGN_IN account null`() = runBlocking {
val loginResult = LoginResult(RC_SIGN_IN, null)
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
logic.onChanged(LoginEvent.OnGoogleSignInResult(loginResult))
verify { view.setLoginStatus(ERROR_AUTH) }
verify { view.setStatusDrawable(ANTENNA_EMPTY) }
verify { view.setAuthButton(RETRY) }
}
@After
fun confirm() {
excludeRecords {
dispatcher.provideUIContext()
testAccount.idToken
}
confirmVerified(dispatcher, userLocator, view, auth, testAccount)
}
}
================================================
FILE: app/src/test/java/com/wiseassblog/spacenotes/NoteDetailLogicTest.kt
================================================
package com.wiseassblog.spacenotes
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.User
import com.wiseassblog.domain.interactor.AnonymousNoteSource
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.domain.interactor.PublicNoteSource
import com.wiseassblog.domain.interactor.RegisteredNoteSource
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.spacenotes.common.MESSAGE_DELETE_SUCCESSFUL
import com.wiseassblog.spacenotes.notedetail.INoteDetailContract
import com.wiseassblog.spacenotes.notedetail.NoteDetailEvent
import com.wiseassblog.spacenotes.notedetail.NoteDetailLogic
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* Philipp Hauer
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class NoteDetailLogicTest {
private val dispatcher: DispatcherProvider = mockk()
private val noteLocator: NoteServiceLocator = mockk()
private val userLocator: UserServiceLocator = mockk()
private val vModel: INoteDetailContract.ViewModel = mockk(relaxed = true)
private val view: INoteDetailContract.View = mockk(relaxed = true)
private val anonymous: AnonymousNoteSource = mockk()
private val registered: RegisteredNoteSource = mockk()
private val public: PublicNoteSource = mockk()
private val auth: AuthSource = mockk()
private lateinit var logic: NoteDetailLogic
//Shout out to Philipp Hauer @philipp_hauer for the snippet below (creating test data) with
//a default argument wrapper function:
fun getNote(creationDate: String = "12:30:30, November 3rd, 2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "satellite_beam",
creator: User? = User(
"8675309",
"Ajahn Chah",
"satellite_beam"
)
) = Note(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator
)
fun getUser(uid: String = "8675309",
name: String = "Ajahn Chah",
profilePicUrl: String = "satellite_beam"
) = User(uid,
name,
profilePicUrl)
fun getLogic(id: String = getNote().creationDate,
isPrivate: Boolean = true) = NoteDetailLogic(
dispatcher,
noteLocator,
userLocator,
vModel,
view,
anonymous,
registered,
public,
auth,
id,
isPrivate
)
@BeforeEach
fun clear() {
clearAllMocks()
every {
dispatcher.provideUIContext()
} returns Dispatchers.Unconfined
}
/**
* When auth presses done, they are finished editing their note. They will be returned to a list
* view of all notes. Depending on if the note isPrivate, and whether or not the user is
* anonymous, will dictate where the note is written to.
*
* a. isPrivate: true, user: null
* b. isPrivate: true, user: not null
* c. isPrivate: false, user: not null
*
* 1. Check current user status: null (anonymous), isPrivate is beside the point if null user
* 2. Create a copy of the note in vM, with updated "content" value
* 3. exit to list activity upon completion
*/
@Test
fun `On Done Click private, not logged in`() = runBlocking {
logic = getLogic()
every {
view.getNoteBody()
} returns getNote().contents
every {
vModel.getNoteState()
} returns getNote()
coEvery {
anonymous.updateNote(getNote(), noteLocator)
} returns Result.build { Unit }
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { null }
//call the unit to be tested
logic.onChanged(NoteDetailEvent.OnDoneClick)
//verify interactions and state if necessary
verify { view.getNoteBody() }
verify { vModel.getNoteState() }
coVerify { auth.getCurrentUser(userLocator) }
coVerify { anonymous.updateNote(getNote(), noteLocator) }
verify { view.startListFeature() }
}
/**
*b:
* 1. get current value of noteBody
* 2. write updated note to repositories
* 3. exit to list activity
*/
@Test
fun `On Done Click private, logged in`() = runBlocking {
logic = getLogic()
every {
view.getNoteBody()
} returns getNote().contents
every {
vModel.getNoteState()
} returns getNote()
every {
vModel.getIsPrivateMode()
} returns true
coEvery {
registered.updateNote(getNote(), noteLocator)
} returns Result.build { Unit }
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
//call the unit to be tested
logic.onChanged(NoteDetailEvent.OnDoneClick)
//verify interactions and state if necessary
verify { view.getNoteBody() }
verify { vModel.getNoteState() }
coVerify { auth.getCurrentUser(userLocator) }
coVerify { registered.updateNote(getNote(), noteLocator) }
verify { view.startListFeature() }
}
/**
*c:
* 1. get current value of noteBody
* 2. write updated note to public
* 3. exit to list activity
*/
@Test
fun `On Done Click public, logged in`() = runBlocking {
logic = getLogic()
every {
view.getNoteBody()
} returns getNote().contents
every {
vModel.getNoteState()
} returns getNote()
every {
vModel.getIsPrivateMode()
} returns false
coEvery {
public.updateNote(getNote(), noteLocator)
} returns Result.build { Unit }
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
//call the unit to be tested
logic.onChanged(NoteDetailEvent.OnDoneClick)
//verify interactions and state if necessary
verify { view.getNoteBody() }
verify { vModel.getNoteState() }
coVerify { auth.getCurrentUser(userLocator) }
coVerify { public.updateNote(getNote(), noteLocator) }
verify { view.startListFeature() }
}
/**
* When auth presses delete, they may wish to delete a note. Show confirmation.
*/
@Test
fun `On Delete Click`() = runBlocking {
logic = getLogic()
every {
view.showConfirmDeleteSnackbar()
} returns Unit
logic.onChanged(NoteDetailEvent.OnDeleteClick)
verify { view.showConfirmDeleteSnackbar() }
}
/**
* When user confirms that they wish to delete a note, delete the note. There are three possible
* places to delete from:
* a. Private Anonymous Repo
* b. Private Registered Repo
* c. Public Repo
*
* a:
* 1. Check status of current user: null
* 2. delete Note from anonymous repo
* 3. show message to indicate if operation was successful
* 3. startListFeature
*/
@Test
fun `On Delete Confirmation successful anonymous`() {
logic = getLogic()
every {
vModel.getNoteState()
} returns getNote()
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { null }
coEvery {
anonymous.deleteNote(getNote(), noteLocator)
} returns Result.build { Unit }
logic.onChanged(NoteDetailEvent.OnDeleteConfirmed)
verify { vModel.getNoteState() }
verify { view.showMessage(MESSAGE_DELETE_SUCCESSFUL) }
verify { view.startListFeature() }
coVerify { anonymous.deleteNote(getNote(), noteLocator) }
coVerify { auth.getCurrentUser(userLocator) }
}
/**
* When user confirms that they wish to delete a note, delete the note. There are three possible
* places to delete from:
* a. Private Anonymous Repo
* b. Private Registered Repo
* c. Public Repo
*
* b:
* 1. Check status of current user: not null
* 2. check isPrivate: true
* 2. delete Note from registered repo
* 3. show message to indicate if operation was successful
* 3. startListFeature
*/
@Test
fun `On Delete Confirmation successful registered`() {
logic = getLogic()
every {
vModel.getNoteState()
} returns getNote()
every {
vModel.getIsPrivateMode()
} returns true
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
coEvery {
registered.deleteNote(getNote(), noteLocator)
} returns Result.build { Unit }
logic.onChanged(NoteDetailEvent.OnDeleteConfirmed)
verify { vModel.getNoteState() }
verify { view.showMessage(MESSAGE_DELETE_SUCCESSFUL) }
verify { view.startListFeature() }
coVerify { registered.deleteNote(getNote(), noteLocator) }
coVerify { auth.getCurrentUser(userLocator) }
}
/**
*
* c:
* 1. Check status of current user: not null
* 2. check isPrivate: false
* 2. delete Note from public repo
* 3. show message to indicate if operation was successful
* 3. startListFeature
*/
@Test
fun `On Delete Confirmation successful public`() {
logic = getLogic()
every {
vModel.getNoteState()
} returns getNote()
every {
vModel.getIsPrivateMode()
} returns false
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
coEvery {
public.deleteNote(getNote(), noteLocator)
} returns Result.build { Unit }
logic.onChanged(NoteDetailEvent.OnDeleteConfirmed)
verify { vModel.getNoteState() }
verify { view.showMessage(MESSAGE_DELETE_SUCCESSFUL) }
verify { view.startListFeature() }
coVerify { public.deleteNote(getNote(), noteLocator) }
coVerify { auth.getCurrentUser(userLocator) }
}
@Test
fun `On Back Click`() {
logic = getLogic()
logic.onChanged(NoteDetailEvent.OnBackClick)
verify { view.startListFeature() }
}
/**
* In on bind, we need to check the status of arguments sent in to the feature via intent,
* check the user status, and call onStart() to render the view.
*
* a. get id from vModel: "" or null (new note)
* b. get id from vModel: not null (not new note)
* c. get user from auth: null
* d. get user from auth: not null
* e. get isPrivate from vModel: true
* f. get isPrivate from vModel: false
*
* a/c:
* 1. Check User state: null
* 2. Check arguments from activity: note id = "", isPrivate = true
* 3. Create new note with date and null user, store in vModel
* 4. render view
* - back set to invisible (only delete or save allowed for new notes
* - start satellite animation
* - set creation date
*/
@Test
fun `On bind a and c`() {
logic = getLogic("", true)
every {
view.getTime()
} returns getNote().creationDate
every {
vModel.getId()
} returns ""
every {
vModel.getIsPrivateMode()
} returns true
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { null }
logic.onChanged(NoteDetailEvent.OnBind)
//creatorId should be null for new note. It will be added if the user saves the note while
//logged in
verify { vModel.setNoteState(getNote(creator = null, contents = "", imageUrl = "satellite_beam")) }
verify { vModel.setIsPrivateMode(true) }
coVerify { auth.getCurrentUser(userLocator) }
verify { vModel.setId("") }
verify { view.getTime() }
verify { view.hideBackButton() }
excludeRecords {
view.setBackgroundImage(any())
view.setDateLabel(any())
view.setNoteBody(any())
}
}
/**
*a: New Note
*d: Not null user
*/
@Test
fun `On bind a and d`() {
logic = getLogic("", true)
every {
view.getTime()
} returns getNote().creationDate
every {
vModel.getId()
} returns ""
every {
vModel.getIsPrivateMode()
} returns true
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
logic.onChanged(NoteDetailEvent.OnBind)
//creatorId should be null for new note. It will be added if the user saves the note while
//logged in
verify { vModel.setNoteState(getNote(creator = getUser(), contents = "", imageUrl = "satellite_beam")) }
verify { vModel.setIsPrivateMode(true) }
coVerify { auth.getCurrentUser(userLocator) }
verify { vModel.setId("") }
verify { view.getTime() }
verify { view.hideBackButton() }
excludeRecords {
view.setBackgroundImage(any())
view.setDateLabel(any())
view.setNoteBody(any())
}
}
/**
*b: Not new Note
*c: User is null
*
* 1. Get current user: null
* 2. Check id: not null
* 3. Query anonymous datasource based on id
*/
@Test
fun `On bind b and c`() {
logic = getLogic(getNote().creationDate, true)
every {
vModel.getId()
} returns getNote().creationDate
every {
vModel.getIsPrivateMode()
} returns true
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { null }
coEvery {
anonymous.getNoteById(getNote().creationDate, noteLocator)
} returns Result.build { getNote() }
logic.onChanged(NoteDetailEvent.OnBind)
//creatorId should be null for new note. It will be added if the user saves the note while
//logged in
verify { vModel.setNoteState(getNote()) }
verify { vModel.setIsPrivateMode(true) }
coVerify { auth.getCurrentUser(userLocator) }
verify { vModel.setId(getNote().creationDate) }
coExcludeRecords {
anonymous.getNoteById(any(), any())
view.setBackgroundImage(any())
view.setDateLabel(any())
view.setNoteBody(any())
}
}
/**
*b: Not new Note
*f: public mode
*/
@Test
fun `On bind b and f`() {
logic = getLogic(getNote().creationDate, false)
every {
vModel.getId()
} returns getNote().creationDate
every {
vModel.getIsPrivateMode()
} returns false
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
coEvery {
public.getNoteById(getNote().creationDate, noteLocator)
} returns Result.build { getNote() }
logic.onChanged(NoteDetailEvent.OnBind)
//creatorId should be null for new note. It will be added if the user saves the note while
//logged in
verify { vModel.setNoteState(getNote()) }
verify { vModel.setIsPrivateMode(false) }
coVerify { auth.getCurrentUser(userLocator) }
coVerify { public.getNoteById(getNote().creationDate, noteLocator) }
verify { vModel.setId(getNote().creationDate) }
coExcludeRecords {
anonymous.getNoteById(any(), any())
view.setBackgroundImage(any())
view.setDateLabel(any())
view.setNoteBody(any())
}
}
/**
*a: New Note
*f: public mode
*/
@Test
fun `On bind a and f`() {
logic = getLogic("", false)
every {
vModel.getId()
} returns ""
every {
view.getTime()
} returns getNote().creationDate
every {
vModel.getIsPrivateMode()
} returns false
coEvery {
auth.getCurrentUser(userLocator)
} returns Result.build { getUser() }
logic.onChanged(NoteDetailEvent.OnBind)
coVerify { auth.getCurrentUser(userLocator) }
verify { vModel.getId() }
verify { view.getTime() }
verify { view.hideBackButton() }
verify {
vModel.setNoteState(
getNote(
contents = ""
)
)
}
coExcludeRecords {
anonymous.getNoteById(any(), any())
view.setBackgroundImage(any())
view.setDateLabel(any())
view.setNoteBody(any())
}
}
/**On start can be considered as a generic event to represent the view telling the listener
* that it's time to rock'n'roll.
*
* 1. Get value of the Note from VM
* 2. Render View
*/
@Test
fun `On start`() = runBlocking {
logic = getLogic(id = getNote().creationDate)
every {
vModel.getNoteState()
} returns getNote()
logic.onChanged(NoteDetailEvent.OnStart)
verify { vModel.getNoteState() }
verify { view.setBackgroundImage(getNote().imageUrl) }
verify { view.setDateLabel(getNote().creationDate) }
verify { view.setNoteBody(getNote().contents) }
}
@AfterEach
fun confirm() {
excludeRecords {
dispatcher.provideUIContext()
vModel.getNoteState()
vModel.setId(any())
vModel.getId()
vModel.setIsPrivateMode(any())
vModel.getIsPrivateMode()
}
confirmVerified(
dispatcher,
noteLocator,
userLocator,
vModel,
view,
anonymous,
registered,
public,
auth
)
}
}
================================================
FILE: app/src/test/java/com/wiseassblog/spacenotes/NoteListLogicTest.kt
================================================
package com.wiseassblog.spacenotes
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.User
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.interactor.AnonymousNoteSource
import com.wiseassblog.domain.interactor.AuthSource
import com.wiseassblog.domain.interactor.PublicNoteSource
import com.wiseassblog.domain.interactor.RegisteredNoteSource
import com.wiseassblog.spacenotes.common.*
import com.wiseassblog.spacenotes.notelist.INoteListContract
import com.wiseassblog.spacenotes.notelist.NoteListAdapter
import com.wiseassblog.spacenotes.notelist.NoteListEvent
import com.wiseassblog.spacenotes.notelist.NoteListLogic
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class NoteListLogicTest {
//This mocking framework is called Mockk
private val dispatcher: DispatcherProvider = mockk()
private val noteLocator: NoteServiceLocator = mockk()
private val userLocator: UserServiceLocator = mockk()
private val vModel: INoteListContract.ViewModel = mockk(relaxed = true)
private val adapter: NoteListAdapter = mockk(relaxed = true)
private val view: INoteListContract.View = mockk(relaxed = true)
private val anonymous: AnonymousNoteSource = mockk()
private val registered: RegisteredNoteSource = mockk()
private val public: PublicNoteSource = mockk()
private val auth: AuthSource = mockk()
private val logic = NoteListLogic(
dispatcher,
noteLocator,
userLocator,
vModel,
adapter,
view,
anonymous,
registered,
public,
auth
)
//Shout out to Philipp Hauer @philipp_hauer for the snippet below (creating test data) with
//a default argument wrapper function:
fun getNote(creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: User? = User(
"8675309",
"Ajahn Chah",
""
)
) = Note(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator
)
fun getUser(uid: String = "8675309",
name: String = "Ajahn Chah",
profilePicUrl: String = ""
) = User(uid,
name,
profilePicUrl)
val getNoteList = listOf(
getNote(),
getNote(),
getNote()
)
@BeforeEach
fun build() {
clearAllMocks()
every { dispatcher.provideUIContext() } returns Dispatchers.Unconfined
}
/**
* New Note events will have two possible event streams, based on whether or not the user is
* in private or public mode
* a: User is in private mode (could be logged in or anonymous, either case is fine)
* c: User is in public mode (must be logged in, but we'll check in the other activity to avoid
* shared mutable state issues)
*
* a:
* 1. check isPrivate status from ViewModel: true
* 2. startNoteDetailFeatureWithExtras with empty string as extra
*/
@Test
fun `On New Note Click Private`() {
//prepare mock interactions
every { vModel.getIsPrivateMode() } returns true
//call the unit to be tested
logic.onChanged(NoteListEvent.OnNewNoteClick)
//verify interactions and state if necessary
verify { view.startNoteDetailFeatureWithExtras("", true) }
}
/**
* b:
* 1.
* 2. startNoteDetailFeatureWithExtras with empty string as extra
*/
@Test
fun `On New Note Click Public`() {
//prepare mock interactions
every { vModel.getIsPrivateMode() } returns false
//call the unit to be tested
logic.onChanged(NoteListEvent.OnNewNoteClick)
//verify interactions and state if necessary
verify { view.startNoteDetailFeatureWithExtras("", false) }
}
/**
* On bind process, called by view in onCreate. Check current user state, write that result to
* vModel, show loading graphic, perform some initialization
*
* a. User is Anonymous
* b. User is Registered
*
* a:
* 1. Display Loading View
* 2. Check for a logged in user from auth: null
* 3. write null to vModel user state
* 4. call On start process
*/
@Test
fun `On bind User anonymous`() = runBlocking {
coEvery { auth.getCurrentUser(userLocator) } returns Result.build { null }
logic.onChanged(NoteListEvent.OnBind)
coVerify { auth.getCurrentUser(userLocator) }
verify { vModel.setUserState(null) }
verify { view.showLoadingView() }
verify { view.setToolbarTitle(MODE_PRIVATE) }
verify { view.setAdapter(adapter) }
verify { adapter.setObserver(logic) }
}
@Test
fun `On bind user registered`() = runBlocking {
coEvery { auth.getCurrentUser(userLocator) } returns Result.build { getUser() }
logic.onChanged(NoteListEvent.OnBind)
coVerify { auth.getCurrentUser(userLocator) }
verify { vModel.setUserState(getUser()) }
verify { view.showLoadingView() }
verify { view.setAdapter(adapter) }
verify { view.setToolbarTitle(MODE_PRIVATE) }
verify { adapter.setObserver(logic) }
}
@Test
fun `On bind error retrieving user`() = runBlocking {
coEvery { auth.getCurrentUser(userLocator) } returns Result.build { throw SpaceNotesError.AuthError }
logic.onChanged(NoteListEvent.OnBind)
coVerify { auth.getCurrentUser(userLocator) }
verify { view.showLoadingView() }
verify { view.setToolbarTitle(MODE_PRIVATE) }
verify { view.setAdapter(adapter) }
verify { adapter.setObserver(logic) }
}
/**
*
* On start basically means that we want to render the UI. This depends on whether the user is
* anonymous, or registered (logged out or in), and if they are in public or private mode
* a. User is anonymous (always private in that case)
* b. User is registered, private mode
* c. User is registered, public mode
*
* a:
*1. Check isPrivate status: true
*2. Check login status in backend if necessary
*3. parse datasources accordingly
*4. draw view accordingly
*/
@Test
fun `On Start anonymous`() = runBlocking {
every { vModel.getIsPrivateMode() } returns true
every { vModel.getUserState() } returns null
coEvery { anonymous.getNotes(noteLocator) } returns Result.build { getNoteList }
logic.onChanged(NoteListEvent.OnStart)
verify { vModel.getIsPrivateMode() }
verify { vModel.getUserState() }
verify { view.showList() }
verify { adapter.submitList(getNoteList) }
coVerify { anonymous.getNotes(noteLocator) }
}
/**
* b:
*1. Check isPrivate status: false
*2. Check login status in backend if necessary
*3. parse datasources accordingly
*4. draw view accordingly
*
*/
@Test
fun `On Start Registered Private`() = runBlocking {
every { vModel.getIsPrivateMode() } returns true
every { vModel.getUserState() } returns getUser()
coEvery { registered.getNotes(noteLocator) } returns Result.build { getNoteList }
logic.onChanged(NoteListEvent.OnStart)
verify { vModel.getIsPrivateMode() }
verify { vModel.getUserState() }
verify { view.showList() }
verify { adapter.submitList(getNoteList) }
coVerify { registered.getNotes(noteLocator) }
}
/**
*c:
*1. Check isPrivate status: false
*2. Check login status in backend if necessary
*3. parse datasources accordingly
*4. draw view accordingly
*
*/
@Test
fun `On Start Registered Public`() = runBlocking {
every { vModel.getIsPrivateMode() } returns false
every { vModel.getUserState() } returns getUser()
coEvery { public.getNotes(noteLocator) } returns Result.build { getNoteList }
logic.onChanged(NoteListEvent.OnStart)
verify { vModel.getIsPrivateMode() }
verify { vModel.getUserState() }
verify { view.showList() }
verify { adapter.submitList(getNoteList) }
coVerify { public.getNotes(noteLocator) }
}
/**
* error:
*1. Check isPrivate status: false
*2. Check login status in backend if necessary
*3. parse datasources accordingly
*4. draw view accordingly
*
*/
@Test
fun `On Start Error`() = runBlocking {
every { vModel.getIsPrivateMode() } returns true
every { vModel.getUserState() } returns getUser()
coEvery { registered.getNotes(noteLocator) } returns Result.build { throw SpaceNotesError.RemoteIOException }
logic.onChanged(NoteListEvent.OnStart)
verify { vModel.getIsPrivateMode() }
verify { vModel.getUserState() }
verify { view.showEmptyState() }
verify { view.showErrorState(MESSAGE_GENERIC_ERROR) }
coVerify { registered.getNotes(noteLocator) }
}
/**
* For empty list, leave the loading animation active.
*/
@Test
fun `On Start a with empty list`() = runBlocking {
every { vModel.getIsPrivateMode() } returns true
every { vModel.getUserState() } returns getUser()
coEvery { registered.getNotes(noteLocator) } returns Result.build { emptyList() }
logic.onChanged(NoteListEvent.OnStart)
verify { vModel.getIsPrivateMode() }
verify { vModel.getUserState() }
verify { view.showEmptyState() }
verify { adapter.submitList(emptyList()) }
coVerify { registered.getNotes(noteLocator) }
}
/**
* c. auth is logged in and in public mode
*1. Check auth status
*2. Check isPrivate status
*3. parse datasources accordingly
*/
@Test
fun `On Start Public Mode`() = runBlocking {
every { vModel.getIsPrivateMode() } returns false
coEvery { public.getNotes(noteLocator) } returns Result.build { getNoteList }
logic.onChanged(NoteListEvent.OnStart)
verify { vModel.getIsPrivateMode() }
verify { view.showList() }
verify { adapter.submitList(getNoteList) }
coVerify { public.getNotes(noteLocator) }
}
/**
* On login click, send auth to Auth Activity in order to manage their login status
*
*1. start login activity
*/
@Test
fun `On Login Click `() {
logic.onChanged(NoteListEvent.OnLoginClick)
verify { view.startLoginFeature() }
}
/**
* On Note Item Click, auth wishes to navigate to a detailed view of the selected item
*a: isPrivate = true
*1. Get appropriate Note from vModel
*2. Get isPrivate from vModel
*2. Start detail Activity with note id passed as extra, and isPrivate result
*/
@Test
fun `On Note Item Click a`() = runBlocking {
every { vModel.getIsPrivateMode() } returns true
every { vModel.getAdapterState() } returns getNoteList
//auth selects first item in adapter
val clickEvent = NoteListEvent.OnNoteItemClick(0)
logic.onChanged(clickEvent)
verify { view.startNoteDetailFeatureWithExtras(getNote().creationDate, true) }
verify { vModel.getAdapterState() }
verify { vModel.getIsPrivateMode() }
}
/**
*b: isPrivate = false
*1. Get appropriate Note from vModel
*2. Get isPrivate from vModel
*2. Start detail Activity with note id passed as extra, and isPrivate result
*/
@Test
fun `On Note Item Click b`() = runBlocking {
every { vModel.getIsPrivateMode() } returns false
every { vModel.getAdapterState() } returns getNoteList
//auth selects first item in adapter
val clickEvent = NoteListEvent.OnNoteItemClick(0)
logic.onChanged(clickEvent)
verify { view.startNoteDetailFeatureWithExtras(getNote().creationDate, false) }
verify { vModel.getAdapterState() }
verify { vModel.getIsPrivateMode() }
}
/**
* When the user wants to switch between private and public mode
* a: User is logged in, currently in private mode
* b: User is logged in, currently in public mode
* c: User is logged out, private only
*
*a:
*1. Check current user status: User
*2. Get isPrivate from vModel: true
*3. Request public notes from repo: Notes
*4. Update view/adapter appropriately
* */
@Test
fun `On Toggle Public Mode`() = runBlocking {
every { vModel.getIsPrivateMode() } returns true andThen false
coEvery { public.getNotes(noteLocator) } returns Result.build { getNoteList }
logic.onChanged(NoteListEvent.OnTogglePublicMode)
verify { vModel.setAdapterState(getNoteList) }
verify { vModel.getIsPrivateMode() }
verify { adapter.submitList(getNoteList) }
coVerify { public.getNotes(noteLocator) }
//ought to be false and MODE_PUBLIC, but
verify { view.setPrivateIcon(false) }
verify { view.setToolbarTitle(MODE_PUBLIC) }
}
/**
* b:
*1. Check current user status: User
*2. Get isPrivate from vModel: false
*3. Request private notes from repo: Notes
*4. Update view/adapter appropriately
* */
@Test
fun `On Toggle Private Mode`() = runBlocking {
every { vModel.getIsPrivateMode() } returns false andThen true
coEvery { registered.getNotes(noteLocator) } returns Result.build { getNoteList }
logic.onChanged(NoteListEvent.OnTogglePublicMode)
verify { vModel.setAdapterState(getNoteList) }
verify { vModel.getIsPrivateMode() }
verify { adapter.submitList(getNoteList) }
coVerify { registered.getNotes(noteLocator) }
verify { view.setPrivateIcon(true) }
verify { view.setToolbarTitle(MODE_PRIVATE) }
}
/**
* C:
*1. Check current user status: no user
*2. Tell user to log in if they want to use the public feature
* */
@Test
fun `On Toggle Private Mode logged out`() = runBlocking {
every { vModel.getUserState() } returns null
logic.onChanged(NoteListEvent.OnTogglePublicMode)
verify { view.showErrorState(MESSAGE_LOGIN) }
}
@After
fun confirm() {
excludeRecords { dispatcher.provideUIContext() }
confirmVerified(
dispatcher,
noteLocator,
userLocator,
vModel,
adapter,
view,
anonymous,
registered,
public,
auth
)
}
}
================================================
FILE: build.gradle
================================================
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
apply from: 'versions.gradle'
addRepos(repositories)
dependencies {
classpath deps.android_gradle_plugin
classpath deps.kotlin.kotlin_gradle_plugin
classpath 'com.google.gms:google-services:4.2.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
addRepos(repositories)
}
task clean(type: Delete) {
delete rootProject.buildDir
}
================================================
FILE: data/.gitignore
================================================
/build
================================================
FILE: data/build.gradle
================================================
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion build_versions.target_sdk
lintOptions {
abortOnError false
}
defaultConfig {
minSdkVersion build_versions.min_sdk
targetSdkVersion build_versions.target_sdk
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildTypes {
debug {
testCoverageEnabled = true
minifyEnabled false
}
release {
testCoverageEnabled = false
minifyEnabled true
}
}
testOptions.unitTests.all {
useJUnitPlatform()
testLogging {
events 'passed', 'skipped', 'failed', 'standardOut', 'standardError'
}
}
}
dependencies {
//to talk to another module within the project:
implementation project(":domain")
implementation deps.room.runtime
implementation deps.firebase.auth
implementation deps.firebase.firestore
implementation deps.play_services.auth
implementation deps.kotlin.kotlin_jre
implementation deps.kotlin.coroutines_core
implementation deps.kotlin.coroutines_android
kapt deps.room.compiler
testImplementation deps.test.junit
testRuntimeOnly deps.test.jupiter_engine
testRuntimeOnly deps.test.vintage_engine
testImplementation deps.test.mockk
testImplementation deps.test.kotlin_junit
}
================================================
FILE: data/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: data/src/main/AndroidManifest.xml
================================================
================================================
FILE: data/src/main/java/com/wiseassblog/data/DataExt.kt
================================================
package com.wiseassblog.data
import android.net.Uri
import com.google.android.gms.tasks.Task
import com.wiseassblog.data.datamodels.AnonymousRoomNote
import com.wiseassblog.data.datamodels.RegisteredRoomNote
import com.wiseassblog.data.datamodels.RegisteredRoomTransaction
import com.wiseassblog.data.datamodels.FirebaseNote
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.NoteTransaction
import com.wiseassblog.domain.domainmodel.TransactionType
import com.wiseassblog.domain.domainmodel.User
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
suspend fun awaitTaskResult(task: Task): T = suspendCoroutine { continuation ->
task.addOnCompleteListener { task ->
if (task.isSuccessful) {
continuation.resume(task.result!!)
} else {
continuation.resumeWithException(task.exception!!)
}
}
}
//Wraps Firebase/GMS calls
suspend fun awaitTaskCompletable(task: Task): Unit = suspendCoroutine { continuation ->
task.addOnCompleteListener { task ->
if (task.isSuccessful) {
continuation.resume(Unit)
} else {
continuation.resumeWithException(task.exception!!)
}
}
}
//Since this.creator is of type Note?, we must give it a default value in such cases.
internal val Note.safeGetUid: String
get() = this.creator?.uid ?: ""
internal val NoteTransaction.safeGetUid: String
get() = this.creator?.uid ?: ""
internal val Uri?.defaultIfEmpty: String
get() = if (this.toString() == "" || this == null) "satellite_beam"
else this.toString()
//"this" refers to the object upon which this extension property is called
internal val Note.toAnonymousRoomNote: AnonymousRoomNote
get() = AnonymousRoomNote(
this.creationDate,
this.contents,
this.upVotes,
this.imageUrl,
this.safeGetUid
)
internal val AnonymousRoomNote.toNote: Note
get() = Note(
this.creationDate,
this.contents,
this.upVotes,
this.imageUrl,
User(this.creatorId)
)
internal val RegisteredRoomTransaction.toTransaction: NoteTransaction
get() = NoteTransaction(
this.creationDate,
this.contents,
this.upVotes,
this.imageUrl,
User(this.creatorId),
this.transactionType.toTransactionType()
)
internal val NoteTransaction.toRegisteredRoomTransaction: RegisteredRoomTransaction
get() = RegisteredRoomTransaction(
this.creationDate,
this.contents,
this.upVotes,
this.imageUrl,
this.safeGetUid,
this.transactionType.value
)
internal val NoteTransaction.toNote: Note
get() = Note(
this.creationDate,
this.contents,
this.upVotes,
this.imageUrl,
User(this.safeGetUid)
)
internal fun String.toTransactionType(): TransactionType {
return if (this.equals(TransactionType.DELETE)) TransactionType.DELETE
else TransactionType.UPDATE
}
internal val Note.toRegisteredRoomNote: RegisteredRoomNote
get() = RegisteredRoomNote(
this.creationDate,
this.contents,
this.upVotes,
this.imageUrl,
this.safeGetUid
)
internal val RegisteredRoomNote.toNote: Note
get() = Note(
this.creationDate,
this.contents,
this.upVotes,
this.imageUrl,
User(this.creatorId)
)
internal val Note.toFirebaseNote: FirebaseNote
get() = FirebaseNote(
this.creationDate,
this.contents,
this.upVotes,
this.imageUrl,
this.safeGetUid
)
internal val FirebaseNote.toNote: Note
get() = Note(
this.creationDate ?: "",
this.contents ?: "",
this.upVotes ?: 0,
this.imageurl ?: "",
User(this.creator ?: "")
)
//Maps from lists of different Data Model types
internal fun List.toNoteListFromAnonymous(): List = this.flatMap {
listOf(it.toNote)
}
internal fun List.toNoteListFromRegistered(): List = this.flatMap {
listOf(it.toNote)
}
internal fun List.toNoteTransactionListFromRegistered(): List = this.flatMap {
listOf(it.toTransaction)
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/auth/FirebaseAuthRepositoryImpl.kt
================================================
package com.wiseassblog.data.auth
import com.google.android.gms.tasks.Tasks
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.GoogleAuthProvider
import com.wiseassblog.data.awaitTaskCompletable
import com.wiseassblog.data.defaultIfEmpty
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.User
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.repository.IAuthRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class FirebaseAuthRepositoryImpl(val auth: FirebaseAuth = FirebaseAuth.getInstance()) : IAuthRepository {
override suspend fun createGoogleUser(idToken: String):
Result = withContext(Dispatchers.IO) {
try {
val credential = GoogleAuthProvider.getCredential(idToken, null)
awaitTaskCompletable(auth.signInWithCredential(credential))
Tasks.await(auth.signInWithCredential(credential))
Result.build { Unit }
} catch (e: Exception) {
Result.build { throw e }
}
}
override suspend fun signOutCurrentUser(): Result {
return Result.build {
auth.signOut()
}
}
override suspend fun deleteCurrentUser(): Result {
return try {
val user = auth.currentUser ?: throw SpaceNotesError.AuthError
awaitTaskCompletable(user.delete())
Result.build { true }
} catch (exception: Exception) {
Result.build { throw exception }
}
}
override suspend fun getCurrentUser(): Result {
val firebaseUser = auth.currentUser
if (firebaseUser == null) return Result.build { null }
else return Result.build {
User(
firebaseUser.uid,
firebaseUser.displayName ?: "",
firebaseUser.photoUrl.defaultIfEmpty
)
}
}
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/datamodels/AnonymousRoomNote.kt
================================================
package com.wiseassblog.data.datamodels
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
//If you're Data Models for a given API require API specific code, then create a separate Data
//Model instead of polluting your domain with platform specific APIs.
@Entity(
tableName = "anonymous_notes",
indices = [Index("creation_date")]
)
data class AnonymousRoomNote(
@PrimaryKey
@ColumnInfo(name = "creation_date")
val creationDate: String,
@ColumnInfo(name = "contents")
val contents: String,
@ColumnInfo(name = "up_votes")
val upVotes: Int,
@ColumnInfo(name = "image_url")
val imageUrl: String,
@ColumnInfo(name = "creator_id")
val creatorId: String
)
================================================
FILE: data/src/main/java/com/wiseassblog/data/datamodels/FirebaseNote.kt
================================================
package com.wiseassblog.data.datamodels
//var and default arguments used due to firestore requiring a no argument constructor to
//deserialize
data class FirebaseNote(
var creationDate: String? = "",
var contents: String? = "",
var upVotes: Int? = 0,
var imageurl: String? = "",
var creator: String? = ""
)
================================================
FILE: data/src/main/java/com/wiseassblog/data/datamodels/RegisteredRoomNote.kt
================================================
package com.wiseassblog.data.datamodels
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
//If you're Data Models for a given API require API specific code, then create a separate Data
//Model instead of polluting your domain with platform specific APIs.
@Entity(
tableName = "registered_notes",
indices = [Index("creation_date")]
)
data class RegisteredRoomNote(
@PrimaryKey
@ColumnInfo(name = "creation_date")
val creationDate: String,
@ColumnInfo(name = "contents")
val contents: String,
@ColumnInfo(name = "up_votes")
val upVotes: Int,
@ColumnInfo(name = "image_url")
val imageUrl: String,
@ColumnInfo(name = "creator_id")
val creatorId: String
)
================================================
FILE: data/src/main/java/com/wiseassblog/data/datamodels/RegisteredRoomTransaction.kt
================================================
package com.wiseassblog.data.datamodels
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
//If you're Data Models for a given API require API specific code, then create a separate Data
//Model instead of polluting your domain with platform specific APIs.
@Entity(
tableName = "transactions",
indices = [Index("creation_date")]
)
data class RegisteredRoomTransaction(
@PrimaryKey
@ColumnInfo(name = "creation_date")
val creationDate: String,
@ColumnInfo(name = "contents")
val contents: String,
@ColumnInfo(name = "up_votes")
val upVotes: Int,
@ColumnInfo(name = "image_url")
val imageUrl: String,
@ColumnInfo(name = "creator_id")
val creatorId: String,
@ColumnInfo(name = "transaction_type")
val transactionType: String
)
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/anonymous/AnonymousNoteDao.kt
================================================
package com.wiseassblog.data.note.anonymous
import androidx.room.*
import com.wiseassblog.data.datamodels.AnonymousRoomNote
@Dao
interface AnonymousNoteDao {
@Query("SELECT * FROM anonymous_notes ORDER BY creation_date")
fun getNotes(): List
@Query("SELECT * FROM anonymous_notes WHERE creation_date = :creationDate ORDER BY creation_date")
fun getNoteById(creationDate: String): AnonymousRoomNote
@Delete
fun deleteNote(noteAnonymous: AnonymousRoomNote)
@Query("DELETE FROM anonymous_notes")
fun deleteAll()
//if update successful, will return number of rows effected, which should be 1
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrUpdateNote(noteAnonymous: AnonymousRoomNote): Long
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/anonymous/RoomAnonymousNoteDatabase.kt
================================================
package com.wiseassblog.data.note.anonymous
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.wiseassblog.data.datamodels.AnonymousRoomNote
private const val DATABASE_ANON = "anonymous"
@Database(entities = [AnonymousRoomNote::class],
version = 1,
exportSchema = false)
abstract class AnonymousNoteDatabase : RoomDatabase(){
abstract fun roomNoteDao(): AnonymousNoteDao
//code below courtesy of https://github.com/googlesamples/android-sunflower; it is open
//source just like this application.
companion object {
// For Singleton instantiation
@Volatile private var instance: AnonymousNoteDatabase? = null
fun getInstance(context: Context): AnonymousNoteDatabase {
return instance ?: synchronized(this) {
instance
?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): AnonymousNoteDatabase {
return Room.databaseBuilder(context, AnonymousNoteDatabase::class.java, DATABASE_ANON)
.build()
}
}
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/anonymous/RoomLocalAnonymousRepositoryImpl.kt
================================================
package com.wiseassblog.data.note.anonymous
import com.wiseassblog.data.toAnonymousRoomNote
import com.wiseassblog.data.toNote
import com.wiseassblog.data.toNoteListFromAnonymous
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.repository.ILocalNoteRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class RoomLocalAnonymousRepositoryImpl(private val noteDao: AnonymousNoteDao) : ILocalNoteRepository {
//Not to be used
override suspend fun deleteAll(): Result = Result.build { throw SpaceNotesError.LocalIOException }
//Not to be used
override suspend fun updateAll(list: List): Result = Result.build { throw SpaceNotesError.LocalIOException }
override suspend fun updateNote(note: Note): Result = withContext(Dispatchers.IO) {
val updated = noteDao.insertOrUpdateNote(note.toAnonymousRoomNote)
when {
//TODO verify that if nothing is updated, the resulting value will be 0
updated == 0L -> Result.build { throw SpaceNotesError.LocalIOException }
else -> Result.build { Unit }
}
}
override suspend fun getNote(id: String): Result = withContext(Dispatchers.IO) { Result.build { noteDao.getNoteById(id).toNote } }
override suspend fun getNotes(): Result> = withContext(Dispatchers.IO) { Result.build { noteDao.getNotes().toNoteListFromAnonymous() } }
override suspend fun deleteNote(note: Note): Result = withContext(Dispatchers.IO) {
noteDao.deleteNote(note.toAnonymousRoomNote)
Result.build { Unit }
}
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/public/FirestorePublicNoteRepositoryImpl.kt
================================================
package com.wiseassblog.data.note.public
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.QuerySnapshot
import com.wiseassblog.data.awaitTaskCompletable
import com.wiseassblog.data.awaitTaskResult
import com.wiseassblog.data.datamodels.FirebaseNote
import com.wiseassblog.data.toFirebaseNote
import com.wiseassblog.data.toNote
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.repository.IPublicNoteRepository
const val COLLECTION_PUBLIC = "public_notes"
object FirestoreRemoteNoteImpl : IPublicNoteRepository {
override suspend fun getNotes(): Result> {
val firestore = FirebaseFirestore.getInstance()
var reference = firestore.collection(COLLECTION_PUBLIC)
return try {
val task = awaitTaskResult(reference.get())
return resultToNoteList(task)
} catch (exception: Exception) {
Result.build { throw exception }
}
}
override suspend fun getNote(id: String): Result {
val firestore = FirebaseFirestore.getInstance()
var reference = firestore.collection(COLLECTION_PUBLIC)
.document(id)
return try {
val task = awaitTaskResult(reference.get())
Result.build {
task.toObject(FirebaseNote::class.java)?.toNote
}
} catch (exception: Exception) {
Result.build { throw exception }
}
}
override suspend fun deleteNote(note: Note): Result {
val firestore = FirebaseFirestore.getInstance()
return try {
awaitTaskCompletable(firestore.collection(COLLECTION_PUBLIC)
.document(note.creationDate)
.delete()
)
Result.build { Unit }
} catch (exception: Exception) {
Result.build { throw exception }
}
}
override suspend fun updateNote(note: Note): Result {
val firestore = FirebaseFirestore.getInstance()
return try {
awaitTaskCompletable(firestore.collection(COLLECTION_PUBLIC)
.document(note.creationDate)
.set(note.toFirebaseNote)
)
Result.build { Unit }
} catch (exception: Exception) {
Result.build { throw exception }
}
}
private fun resultToNoteList(result: QuerySnapshot?): Result> {
val noteList = mutableListOf()
result?.forEach { documentSnapshop ->
noteList.add(documentSnapshop.toObject(FirebaseNote::class.java).toNote)
}
return Result.build {
noteList
}
}
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/registered/FirestorePrivateRemoteNoteImpl.kt
================================================
package com.wiseassblog.data.note.registered
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.QuerySnapshot
import com.wiseassblog.data.awaitTaskCompletable
import com.wiseassblog.data.awaitTaskResult
import com.wiseassblog.data.datamodels.FirebaseNote
import com.wiseassblog.data.toFirebaseNote
import com.wiseassblog.data.toNote
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.NoteTransaction
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.repository.IRemoteNoteRepository
private const val COLLECTION_NAME = "notes"
class FirestorePrivateRemoteNoteImpl(
val firestore: FirebaseFirestore = FirebaseFirestore.getInstance()
) : IRemoteNoteRepository {
//Currently handled in RegisteredNoteRepositoryImpl
override suspend fun synchronizeTransactions(transactions: List): Result = Result.build { Unit }
override suspend fun getNotes(): Result> {
var reference = firestore.collection(COLLECTION_NAME)
return try {
val task = awaitTaskResult(reference.get())
return resultToNoteList(task)
} catch (exception: Exception) {
Result.build { throw exception }
}
}
private fun resultToNoteList(result: QuerySnapshot?): Result> {
val noteList = mutableListOf()
result?.forEach { documentSnapshop ->
noteList.add(documentSnapshop.toObject(FirebaseNote::class.java).toNote)
}
return Result.build {
noteList
}
}
override suspend fun getNote(id: String): Result {
var reference = firestore.collection(COLLECTION_NAME)
.document(id)
return try {
val task = awaitTaskResult(reference.get())
Result.build {
task.toObject(FirebaseNote::class.java)?.toNote
}
} catch (exception: Exception) {
Result.build { throw exception }
}
}
override suspend fun deleteNote(note: Note): Result {
return try {
awaitTaskCompletable(firestore.collection(COLLECTION_NAME)
.document(note.creationDate)
.delete()
)
Result.build { Unit }
} catch (exception: Exception) {
Result.build { throw exception }
}
}
override suspend fun updateNote(note: Note): Result {
return try {
awaitTaskCompletable(firestore.collection(COLLECTION_NAME)
.document(note.creationDate)
.set(note.toFirebaseNote)
)
Result.build { Unit }
} catch (exception: Exception) {
Result.build { throw exception }
}
}
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/registered/RegisteredNoteDao.kt
================================================
package com.wiseassblog.data.note.registered
import androidx.room.*
import com.wiseassblog.data.datamodels.RegisteredRoomNote
@Dao
interface RegisteredNoteDao {
@Query("SELECT * FROM registered_notes ORDER BY creation_date")
fun getNotes(): List
@Query("SELECT * FROM registered_notes WHERE creation_date = :creationDate ORDER BY creation_date")
fun getNoteById(creationDate: String): RegisteredRoomNote
@Delete
fun deleteNote(note: RegisteredRoomNote)
@Query("DELETE FROM registered_notes")
fun deleteAll()
//if update successful, will return number of rows effected, which should be 1
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrUpdateNote(note: RegisteredRoomNote): Long
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/registered/RegisteredNoteRepositoryImpl.kt
================================================
package com.wiseassblog.data.note.registered
import com.wiseassblog.data.toNote
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.NoteTransaction
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.TransactionType
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.repository.ILocalNoteRepository
import com.wiseassblog.domain.repository.IRemoteNoteRepository
class RegisteredNoteRepositoryImpl(val remote: IRemoteNoteRepository,
val cache: ILocalNoteRepository) : IRemoteNoteRepository {
/**
* Since n number of transactions may need to be pushed to the remote, and may not all
* be successful, it's rather tricky to return a specific result. I figured that the next
* best thing would be to return an error if any of them fail, to at least inform the
* user that something didn't go as planned.
*/
override suspend fun synchronizeTransactions(transactions: List): Result {
//track results
val resultList = mutableListOf>()
transactions.forEach {
if (it.transactionType == TransactionType.UPDATE) remote.updateNote(it.toNote)
.also { updateResult ->
resultList.add(updateResult)
}
else remote.deleteNote(it.toNote).also { deleteResult ->
resultList.add(deleteResult)
}
}
var successful = true
//if any result was an error, throw a generic error
resultList.forEach {
if (it is Result.Error) successful = false
}
if (successful) return Result.build { Unit }
else return Result.build { throw SpaceNotesError.RemoteIOException }
}
override suspend fun getNotes(): Result> {
val remoteResult = remote.getNotes()
when (remoteResult) {
is Result.Value -> {
cache.deleteAll()
cache.updateAll(remoteResult.value)
}
is Result.Error -> {
return cache.getNotes()
}
}
return remoteResult
}
override suspend fun getNote(id: String): Result {
val remoteResult = remote.getNote(id)
return if (remoteResult is Result.Error) cache.getNote(id)
else remoteResult
}
override suspend fun deleteNote(note: Note): Result = remote.deleteNote(
note
)
override suspend fun updateNote(note: Note): Result = remote.updateNote(
note
)
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/registered/RegisteredTransactionDao.kt
================================================
package com.wiseassblog.data.note.registered
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.wiseassblog.data.datamodels.RegisteredRoomTransaction
@Dao
interface RegisteredTransactionDao {
@Query("SELECT * FROM transactions ORDER BY creation_date")
fun getTransactions(): List
@Query("DELETE FROM transactions")
fun deleteAll()
//if update successful, will return number of rows effected, which should be 1
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrUpdateTransaction(transaction: RegisteredRoomTransaction): Long
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/registered/RoomLocalCacheImpl.kt
================================================
package com.wiseassblog.data.note.registered
import com.wiseassblog.data.toNote
import com.wiseassblog.data.toNoteListFromRegistered
import com.wiseassblog.data.toRegisteredRoomNote
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.repository.ILocalNoteRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* This datasource is used by the RegisteredNoteRepository
*/
class RoomLocalCacheImpl(private val noteDao: RegisteredNoteDao) : ILocalNoteRepository {
override suspend fun deleteAll(): Result = withContext(Dispatchers.IO) {
noteDao.deleteAll()
Result.build { Unit }
}
override suspend fun updateAll(list: List): Result = withContext(Dispatchers.IO) {
list.forEach {
noteDao.insertOrUpdateNote(it.toRegisteredRoomNote)
}
Result.build { Unit }
}
override suspend fun updateNote(note: Note): Result = withContext(Dispatchers.IO) {
noteDao.insertOrUpdateNote(note.toRegisteredRoomNote)
Result.build { Unit }
}
override suspend fun getNote(id: String): Result = withContext(Dispatchers.IO) {
Result.build { noteDao.getNoteById(id).toNote }
}
override suspend fun getNotes(): Result> = withContext(Dispatchers.IO) {
Result.build { noteDao.getNotes().toNoteListFromRegistered() }
}
override suspend fun deleteNote(note: Note): Result = withContext(Dispatchers.IO) {
noteDao.deleteNote(note.toRegisteredRoomNote)
Result.build { Unit }
}
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/note/registered/RoomRegisteredNoteDatabase.kt
================================================
package com.wiseassblog.data.note.registered
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.wiseassblog.data.datamodels.RegisteredRoomNote
private const val DATABASE_REG = "registered"
/**
* This database is used as a "cache" for registered users.
*/
@Database(entities = [RegisteredRoomNote::class],
version = 1,
exportSchema = false)
abstract class RegisteredNoteDatabase : RoomDatabase(){
abstract fun roomNoteDao(): RegisteredNoteDao
//code below courtesy of https://github.com/googlesamples/android-sunflower; it is open
//source just like this application.
companion object {
// For Singleton instantiation
@Volatile private var instance: RegisteredNoteDatabase? = null
fun getInstance(context: Context): RegisteredNoteDatabase {
return instance ?: synchronized(this) {
instance
?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): RegisteredNoteDatabase {
return Room.databaseBuilder(context, RegisteredNoteDatabase::class.java, DATABASE_REG)
.build()
}
}
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/transaction/RoomRegisteredTransactionDatabase.kt
================================================
package com.wiseassblog.data.transaction
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.wiseassblog.data.datamodels.RegisteredRoomTransaction
import com.wiseassblog.data.note.registered.RegisteredTransactionDao
private const val DATABASE_TRANSACTION = "transactions"
/**
* This database is used as a "cache" for registered users.
*/
@Database(entities = [RegisteredRoomTransaction::class],
version = 1,
exportSchema = false)
abstract class RoomRegisteredTransactionDatabase : RoomDatabase() {
abstract fun roomTransactionDao(): RegisteredTransactionDao
//code below courtesy of https://github.com/googlesamples/android-sunflower; it is open
//source just like this application.
companion object {
// For Singleton instantiation
@Volatile
private var instance: RoomRegisteredTransactionDatabase? = null
fun getInstance(context: Context): RoomRegisteredTransactionDatabase {
return instance
?: synchronized(this) {
instance
?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): RoomRegisteredTransactionDatabase {
return Room.databaseBuilder(context, RoomRegisteredTransactionDatabase::class.java, DATABASE_TRANSACTION)
.build()
}
}
}
================================================
FILE: data/src/main/java/com/wiseassblog/data/transaction/RoomTransactionRepositoryImpl.kt
================================================
package com.wiseassblog.data.transaction
import com.wiseassblog.data.note.registered.RegisteredTransactionDao
import com.wiseassblog.data.toNoteTransactionListFromRegistered
import com.wiseassblog.data.toRegisteredRoomTransaction
import com.wiseassblog.domain.domainmodel.NoteTransaction
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.repository.ITransactionRepository
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.runBlocking
class RoomTransactionRepositoryImpl(val transactionDao: RegisteredTransactionDao) : ITransactionRepository {
override suspend fun getTransactions():
Result> = runBlocking(IO) {
Result.build {
transactionDao.getTransactions().toNoteTransactionListFromRegistered()
}
}
override suspend fun deleteTransactions(): Result = runBlocking(IO) {
Result.build {
transactionDao.deleteAll()
}
}
override suspend fun updateTransactions(transaction: NoteTransaction):
Result = runBlocking(IO) {
Result.build {
transactionDao.insertOrUpdateTransaction(
transaction.toRegisteredRoomTransaction
).toUnit()
}
}
private fun Long.toUnit(): Unit = Unit
}
================================================
FILE: data/src/main/res/values/strings.xml
================================================
data
================================================
FILE: data/src/test/java/com/wiseassblog/data/ExtTest.kt
================================================
package com.wiseassblog.data
import com.wiseassblog.data.datamodels.AnonymousRoomNote
import com.wiseassblog.data.datamodels.RegisteredRoomNote
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.User
import org.junit.Test
import kotlin.test.assertTrue
class ExtTest{
fun getNote(creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: User? = User(
"8675309",
"Ajahn Chah",
""
)
) = Note(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator
)
fun getAnonymousRoomNote(creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: String = "8675309"
) = AnonymousRoomNote(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creatorId = creator
)
fun getRegisteredRoomNote(creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: String = "8675309"
) = RegisteredRoomNote(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creatorId = creator
)
@Test
fun testExtensionFlatMap(){
val roomNoteList = listOf(getAnonymousRoomNote(), getAnonymousRoomNote(), getAnonymousRoomNote(contents = "third"))
val result = roomNoteList.toNoteListFromAnonymous()
assertTrue { result.contains(getAnonymousRoomNote().toNote) }
}
}
================================================
FILE: data/src/test/java/com/wiseassblog/data/RegisteredNoteRepositoryTest.kt
================================================
package com.wiseassblog.data
import com.wiseassblog.data.note.registered.RegisteredNoteRepositoryImpl
import com.wiseassblog.domain.DispatcherProvider
import com.wiseassblog.domain.domainmodel.*
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.repository.ILocalNoteRepository
import com.wiseassblog.domain.repository.IRemoteNoteRepository
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RegisteredNoteRepositoryTest {
val dispatcher: DispatcherProvider = mockk()
val cache: ILocalNoteRepository = mockk()
val remote: IRemoteNoteRepository = mockk()
val repo = RegisteredNoteRepositoryImpl(remote, cache)
fun getNote(creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: User? = User(
"8675309",
"Ajahn Chah",
""
)
) = Note(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator
)
fun getTransaction(
creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: User? = User(
"8675309",
"Ajahn Chah",
""
),
transactionType: TransactionType = TransactionType.UPDATE
) = NoteTransaction(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator,
transactionType = transactionType
)
@BeforeEach
fun setUpRedundantMocks() {
clearAllMocks()
every { dispatcher.provideIOContext() } returns Dispatchers.Unconfined
}
/**
* On get notes, we first request Notes from the Remote. Data is returned either from the remote
* or local based on that result.
* a: Success
* b: Error
*
* a:
* 1. Request Data from Remote: Success
* 2. Update the Local repository
* 3. Return data from Remote
*/
@Test
fun `Get Notes Success`() = runBlocking {
val testList = listOf(getNote(), getNote(), getNote())
coEvery { remote.getNotes() } returns Result.build { testList }
coEvery { cache.updateAll(testList) } returns Result.build { Unit }
coEvery { cache.deleteAll() } returns Result.build { Unit }
val result = repo.getNotes()
coVerify { remote.getNotes() }
coVerify { cache.deleteAll() }
coVerify { cache.updateAll(testList) }
if (result is Result.Value) assertEquals(result.value, testList)
else assertTrue { false }
}
/**
* b:
* 1. Request Data from Remote: Error
* 2. Return Data from Local
*/
@Test
fun `Get Notes Fail`() = runBlocking {
val testNote = getNote()
val testList = listOf(getNote(), getNote(), getNote())
coEvery { remote.getNotes() } returns Result.build { throw SpaceNotesError.RemoteIOException }
coEvery { cache.getNotes() } returns Result.build { testList }
val result = repo.getNotes()
coVerify { remote.getNotes() }
coVerify { cache.getNotes() }
if (result is Result.Value) assertEquals(result.value, testList)
else assertTrue { false }
}
/**
* On get note, we first request Notes from the Remote. Data is returned either from the remote
* or local based on that result.
* a: Success
* b: Fail
*
* a:
* 1. Request Data from Remote: Success
* 2. Return data from Remote
*/
@Test
fun `Get Note Success`() = runBlocking {
val testNote = getNote()
coEvery { remote.getNote(testNote.creationDate) } returns Result.build { testNote }
val result = repo.getNote(testNote.creationDate)
coVerify { remote.getNote(testNote.creationDate) }
if (result is Result.Value) assertEquals(result.value, testNote)
else assertTrue { false }
}
/**
* b:
* 1. Request Data from Remote: Fail
* 2. Return Data from Local
*/
@Test
fun `Get Note Fail`() = runBlocking {
val testNote = getNote()
coEvery { remote.getNote(testNote.creationDate) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
coEvery { cache.getNote(testNote.creationDate) } returns Result.build { testNote }
val result = repo.getNote(testNote.creationDate)
coVerify { remote.getNote(testNote.creationDate) }
coVerify { cache.getNote(testNote.creationDate) }
if (result is Result.Value) assertEquals(result.value, testNote)
else assertTrue { false }
}
/**
* On delete note:
* a: Success
* b: Fail
*
* a:
* 1. Delete Data from Remote: Success
* 2. Return: Success
*/
@Test
fun `Delete Note Success`() = runBlocking {
val testNote = getNote()
coEvery { remote.deleteNote(testNote) } returns Result.build {
Unit
}
val result = repo.deleteNote(testNote)
coVerify { remote.deleteNote(testNote) }
assertTrue { result is Result.Value }
}
/**
* b:
* 1. Delete Data from Remote: Fail
* 2. Return: Error
*/
@Test
fun `Delete Note Fail`() = runBlocking {
val testNote = getNote()
coEvery { remote.deleteNote(testNote) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
val result = repo.deleteNote(testNote)
coVerify { remote.deleteNote(testNote) }
assertTrue { result is Result.Error }
}
/**
* On delete note:
* a: Success
* b: Fail
*
* a:
* 1. Update Data from Remote: Success
* 2. Return: Success
*/
@Test
fun `Update Note Success`() = runBlocking {
val testNote = getNote()
coEvery { remote.updateNote(testNote) } returns Result.build {
Unit
}
val result = repo.updateNote(testNote)
coVerify { remote.updateNote(testNote) }
assertTrue { result is Result.Value }
}
/**
* b:
* 1. Update Data from Remote: Fail
* 2. Return: Error
*/
@Test
fun `Update Note Fail`() = runBlocking {
val testNote = getNote()
coEvery { remote.updateNote(testNote) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
val result = repo.updateNote(testNote)
coVerify { remote.updateNote(testNote) }
assertTrue { result is Result.Error }
}
/**
* On Synchronize Transactions, we want to map transactions to Note objects, and push them
* all to the Remote Repo:
* a: Success
* b: Fail
*
* a:
* 1. Pass Data to Remote: Success
* 2. Return: Unit
*/
@Test
fun `Synchronize Transactions Success`() = runBlocking {
val updateTransaction = getTransaction()
val deleteTransaction = getTransaction(transactionType = TransactionType.DELETE)
val testList = listOf(updateTransaction, deleteTransaction)
coEvery { remote.updateNote(updateTransaction.toNote) } returns Result.build {
Unit
}
coEvery { remote.deleteNote(deleteTransaction.toNote) } returns Result.build {
Unit
}
val result = repo.synchronizeTransactions(testList)
coVerify { remote.updateNote(updateTransaction.toNote) }
coVerify { remote.deleteNote(deleteTransaction.toNote) }
assertTrue { result is Result.Value }
}
/**
* b:
* 1. Pass Data from Remote: Success (once), Fail (once)
* 2. Return: Error
*/
@Test
fun `Synchronize Transactions Fail`() = runBlocking {
val updateTransaction = getTransaction()
val deleteTransaction = getTransaction(transactionType = TransactionType.DELETE)
val testList = listOf(updateTransaction, deleteTransaction)
coEvery { remote.updateNote(updateTransaction.toNote) } returns Result.build {
Unit
}
coEvery { remote.deleteNote(deleteTransaction.toNote) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
val result = repo.synchronizeTransactions(testList)
coVerify { remote.updateNote(updateTransaction.toNote) }
coVerify { remote.deleteNote(deleteTransaction.toNote) }
assertTrue { result is Result.Error }
}
@AfterEach
fun confirm() {
confirmVerified(
dispatcher,
cache,
remote
)
}
}
================================================
FILE: domain/.gitignore
================================================
/build
================================================
FILE: domain/build.gradle
================================================
apply plugin: 'java-library'
apply plugin: 'kotlin'
dependencies {
implementation deps.kotlin.coroutines_core
testImplementation deps.test.junit
testImplementation deps.test.mockk
testImplementation deps.test.kotlin_junit
}
sourceCompatibility = "1.8"
targetCompatibility = "1.8"
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/DispatcherProvider.kt
================================================
package com.wiseassblog.domain
import kotlinx.coroutines.Dispatchers
import kotlin.coroutines.CoroutineContext
object DispatcherProvider {
fun provideUIContext(): CoroutineContext {
return Dispatchers.Main
}
fun provideIOContext(): CoroutineContext {
return Dispatchers.IO
}
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/domainmodel/Note.kt
================================================
package com.wiseassblog.domain.domainmodel
data class Note(val creationDate:String,
val contents:String,
val upVotes: Int,
//why String? some times it will be Int from Android Resources, or URL String
val imageUrl: String,
val creator: User?)
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/domainmodel/NoteTransaction.kt
================================================
package com.wiseassblog.domain.domainmodel
data class NoteTransaction(
val creationDate:String,
val contents:String,
val upVotes: Int,
val imageUrl: String,
val creator: User?,
val transactionType: TransactionType
)
enum class TransactionType(val value: String) {
UPDATE( "update"),
DELETE( "delete"),
}
fun Note.toTransaction(type: TransactionType): NoteTransaction = NoteTransaction(
creationDate,
contents,
upVotes,
imageUrl,
creator,
type
)
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/domainmodel/Result.kt
================================================
package com.wiseassblog.domain.domainmodel
/**
* Result Wrapper
*/
sealed class Result {
data class Value(val value: V) : Result()
object Loading : Result()
data class Error(val error: E) : Result()
companion object Factory{
//higher order functions take functions as parameters or return a function
//Kotlin has function types name: () -> V
inline fun build(function: () -> V): Result =
try {
Value(function.invoke())
} catch (e: java.lang.Exception) {
Error(e)
}
fun buildLoading(): Result {
return Loading
}
}
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/domainmodel/User.kt
================================================
package com.wiseassblog.domain.domainmodel
data class User(val uid: String,
val name: String = "",
val profilePicUrl: String = "satellite_beam")
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/error/SpaceNotesError.kt
================================================
package com.wiseassblog.domain.error
import java.lang.Exception
sealed class SpaceNotesError: Exception() {
object LocalIOException: SpaceNotesError()
object RemoteIOException: SpaceNotesError()
object NetworkUnavailableException: SpaceNotesError()
object AuthError: SpaceNotesError()
object TransactionIOException : SpaceNotesError()
}
const val ERROR_UPDATE_FAILED = "Update operation unsuccessful."
const val ERROR_DELETE_FAILED = "Delete operation unsuccessful."
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/interactor/AnonymousNoteSource.kt
================================================
package com.wiseassblog.domain.interactor
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
class AnonymousNoteSource {
suspend fun getNotes(locator: NoteServiceLocator):
Result> = locator.localAnon.getNotes()
suspend fun getNoteById(id: String, locator: NoteServiceLocator):
Result = locator.localAnon.getNote(id)
suspend fun updateNote(note: Note, locator: NoteServiceLocator):
Result = locator.localAnon.updateNote(note)
suspend fun deleteNote(note: Note, locator: NoteServiceLocator):
Result = locator.localAnon.deleteNote(note)
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/interactor/AuthSource.kt
================================================
package com.wiseassblog.domain.interactor
import com.wiseassblog.domain.servicelocator.UserServiceLocator
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.User
class AuthSource {
suspend fun getCurrentUser(locator: UserServiceLocator):
Result = locator.authRepository.getCurrentUser()
suspend fun deleteCurrentUser(locator: UserServiceLocator):
Result = locator.authRepository.deleteCurrentUser()
suspend fun signOutCurrentUser(locator: UserServiceLocator):
Result = locator.authRepository.signOutCurrentUser()
suspend fun createGoogleUser(idToken: String, locator: UserServiceLocator):
Result = locator.authRepository.createGoogleUser(idToken)
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/interactor/PublicNoteSource.kt
================================================
package com.wiseassblog.domain.interactor
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
class PublicNoteSource {
suspend fun getNotes(locator: NoteServiceLocator): Result> = locator
.remotePublic
.getNotes()
suspend fun getNoteById(id: String,
locator: NoteServiceLocator): Result = locator
.remotePublic
.getNote(id)
suspend fun updateNote(note: Note,
locator: NoteServiceLocator): Result = locator
.remotePublic
.updateNote(note)
suspend fun deleteNote(note: Note,
locator: NoteServiceLocator): Result = locator
.remotePublic
.deleteNote(note)
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/interactor/RegisteredNoteSource.kt
================================================
package com.wiseassblog.domain.interactor
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import com.wiseassblog.domain.domainmodel.*
import com.wiseassblog.domain.repository.IRemoteNoteRepository
import com.wiseassblog.domain.repository.ITransactionRepository
class RegisteredNoteSource {
suspend fun getNotes(locator: NoteServiceLocator): Result> {
val transactionResult = locator.transactionReg.getTransactions()
when (transactionResult) {
is Result.Value -> {
//if items exist in transaction cache:
if (transactionResult.value.size != 0) synchronizeTransactionCache(
transactionResult.value,
locator.remoteReg,
locator.transactionReg
)
}
is Result.Error -> {
//For now we'll just continue to ask remote for the latest data
}
}
return locator.remoteReg.getNotes()
}
private suspend fun synchronizeTransactionCache(
transactions: List,
remoteReg: IRemoteNoteRepository,
transactionReg: ITransactionRepository) {
val synchronizationResult = remoteReg.synchronizeTransactions(transactions)
//if synchronization was successful, delete items from the transaction cache
when (synchronizationResult) {
is Result.Value -> transactionReg.deleteTransactions()
is Result.Error -> {
//"Again, not necessarily a fatal error"
}
}
}
suspend fun getNoteById(id: String,
locator: NoteServiceLocator):
Result = locator.remoteReg.getNote(id)
suspend fun updateNote(note: Note,
locator: NoteServiceLocator): Result {
val remoteResult = locator.remoteReg.updateNote(note)
if (remoteResult is Result.Value) return remoteResult
else return locator.transactionReg.updateTransactions(
note.toTransaction(TransactionType.UPDATE)
)
}
suspend fun deleteNote(note: Note,
locator: NoteServiceLocator): Result {
val remoteResult = locator.remoteReg.deleteNote(note)
if (remoteResult is Result.Value) return remoteResult
else return locator.transactionReg.updateTransactions(
note.toTransaction(TransactionType.DELETE)
)
}
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/repository/IAuthRepository.kt
================================================
package com.wiseassblog.domain.repository
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.User
import kotlinx.coroutines.channels.SendChannel
interface IAuthRepository {
// suspend fun setAuthStateListener(channel: SendChannel>): Result
suspend fun getCurrentUser(): Result
suspend fun signOutCurrentUser(): Result
suspend fun deleteCurrentUser(): Result
suspend fun createGoogleUser(idToken: String): Result
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/repository/ILocalNoteRepository.kt
================================================
package com.wiseassblog.domain.repository
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
interface ILocalNoteRepository {
suspend fun getNotes(): Result>
suspend fun getNote(id: String): Result
suspend fun deleteNote(note: Note): Result
suspend fun deleteAll(): Result
suspend fun updateAll(list: List): Result
suspend fun updateNote(note: Note): Result
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/repository/IPublicNoteRepository.kt
================================================
package com.wiseassblog.domain.repository
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.NoteTransaction
import com.wiseassblog.domain.domainmodel.Result
import kotlinx.coroutines.channels.Channel
interface IPublicNoteRepository {
suspend fun getNotes():Result>
suspend fun getNote(id: String): Result
suspend fun deleteNote(note: Note): Result
suspend fun updateNote(note: Note):Result
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/repository/IRemoteNoteRepository.kt
================================================
package com.wiseassblog.domain.repository
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.NoteTransaction
import com.wiseassblog.domain.domainmodel.Result
import kotlinx.coroutines.channels.Channel
interface IRemoteNoteRepository {
suspend fun synchronizeTransactions(transactions: List): Result
suspend fun getNotes():Result>
suspend fun getNote(id: String): Result
suspend fun deleteNote(note: Note): Result
suspend fun updateNote(note: Note):Result
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/repository/ITransactionRepository.kt
================================================
package com.wiseassblog.domain.repository
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.NoteTransaction
interface ITransactionRepository {
suspend fun getTransactions():Result>
suspend fun deleteTransactions(): Result
suspend fun updateTransactions(transaction: NoteTransaction):Result
}
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/servicelocator/NoteServiceLocator.kt
================================================
package com.wiseassblog.domain.servicelocator
import com.wiseassblog.domain.repository.ILocalNoteRepository
import com.wiseassblog.domain.repository.IPublicNoteRepository
import com.wiseassblog.domain.repository.IRemoteNoteRepository
import com.wiseassblog.domain.repository.ITransactionRepository
class NoteServiceLocator(val localAnon: ILocalNoteRepository,
val remoteReg: IRemoteNoteRepository,
val transactionReg: ITransactionRepository,
val remotePublic: IPublicNoteRepository)
================================================
FILE: domain/src/main/java/com/wiseassblog/domain/servicelocator/UserServiceLocator.kt
================================================
package com.wiseassblog.domain.servicelocator
import com.wiseassblog.domain.repository.IAuthRepository
class UserServiceLocator(val authRepository: IAuthRepository)
================================================
FILE: domain/src/test/java/com/wiseassblog/domain/AnonymousNoteSourceTest.kt
================================================
package com.wiseassblog.domain
import com.wiseassblog.domain.domainmodel.Note
import com.wiseassblog.domain.domainmodel.Result
import com.wiseassblog.domain.domainmodel.User
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.interactor.AnonymousNoteSource
import com.wiseassblog.domain.repository.ILocalNoteRepository
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import io.mockk.*
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Anonymous Note Source is for users that have not authenticated any social media accounts (such as
* via Google Sign In)
* Anonymous users have access to:
* - A local Repository; nothing else.
*/
class AnonymousNoteSourceTest {
val anonSource = AnonymousNoteSource()
val locator: NoteServiceLocator = mockk()
val localNoteRepo: ILocalNoteRepository = mockk()
//Shout out to Philipp Hauer @philipp_hauer for the snippet below (creating test data) with
//a default argument wrapper function:
fun getNote(creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: User? = User(
"8675309",
"Ajahn Chah",
""
)
) = Note(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator
)
@BeforeEach
fun setUpRedundantMocks() {
clearAllMocks()
}
/**
* When an anonymous user navigates to the List Feature, we retrieve data from a local
* repository only.
*
* a. Retrieve Notes Successfully
* b. Error cases
*
* a:
*1. Request data from localNoteRepo
*2.
*
*/
@Test
fun `On Get Notes Successful`() = runBlocking {
//1 Set up Test data and mock responses
val testList = listOf(getNote(), getNote(), getNote())
every { locator.localAnon } returns localNoteRepo
coEvery { localNoteRepo.getNotes() } returns Result.build { testList }
//2 Call the Unit to be tested
val result: Result> = anonSource.getNotes(locator)
//3 Verify behaviour and state
verify { locator.localAnon }
coVerify { localNoteRepo.getNotes() }
if (result is Result.Value) assertEquals(result.value, testList)
else assertTrue { false }
}
/**
*b:
*1.
*
*/
@Test
fun `On Get Notes Error`() = runBlocking {
every { locator.localAnon } returns localNoteRepo
coEvery { localNoteRepo.getNotes() } returns Result.build { throw SpaceNotesError.LocalIOException }
//2 Call the Unit to be tested
val result: Result> = anonSource.getNotes(locator)
//3 Verify behaviour and state
verify { locator.localAnon }
coVerify { localNoteRepo.getNotes() }
assert(result is Result.Error)
}
/**
* Retrieve a given note based on a passed in id
* a. Note retrieved successfully
* b. Error
*
* 1. Get note from repo
*/
@Test
fun `On Get Note Successful`() = runBlocking {
val testNote = getNote()
every { locator.localAnon } returns localNoteRepo
coEvery { localNoteRepo.getNote(testNote.creationDate) } returns Result.build {
testNote
}
//2 Call the Unit to be tested
val result: Result = anonSource.getNoteById(
testNote.creationDate,
locator
)
//3 Verify behaviour and state
verify { locator.localAnon }
coVerify { localNoteRepo.getNote(testNote.creationDate) }
if (result is Result.Value) assertEquals(result.value, testNote)
else assertTrue { false }
}
/**
*b:
*/
@Test
fun `On Get Note Error`() = runBlocking {
val testId = getNote().creationDate
every { locator.localAnon } returns localNoteRepo
coEvery { localNoteRepo.getNote(testId) } returns Result.build { throw SpaceNotesError.LocalIOException }
//2 Call the Unit to be tested
val result: Result = anonSource.getNoteById(testId, locator)
//3 Verify behaviour and state
verify { locator.localAnon }
coVerify { localNoteRepo.getNote(testId) }
assert(result is Result.Error)
}
/**
* When an anonymous user is done editing their note, attempt to update the value
* in the local repository
* a. Success: true
* b. Error
*/
@Test
fun `On Update Note Success`() = runBlocking {
val testNote = getNote()
every { locator.localAnon } returns localNoteRepo
coEvery { localNoteRepo.updateNote(testNote) } returns Result.build {
Unit
}
//2 Call the Unit to be tested
val result: Result = anonSource.updateNote(
testNote,
locator
)
//3 Verify behaviour and state
verify { locator.localAnon }
coVerify { localNoteRepo.updateNote(testNote) }
if (result is Result.Value) assertTrue(true)
else assertTrue { false }
}
/**
* b:
*/
@Test
fun `On Update Note Error`() = runBlocking {
val testNote = getNote()
every { locator.localAnon } returns localNoteRepo
coEvery { localNoteRepo.updateNote(testNote) } returns Result.build {
throw SpaceNotesError.LocalIOException
}
//2 Call the Unit to be tested
val result: Result = anonSource.updateNote(
testNote,
locator
)
verify { locator.localAnon }
coVerify { localNoteRepo.updateNote(testNote) }
assertTrue(result is Result.Error)
}
/**
* When the user wishes to delete a note then we try to delete the note.
*a. successfully deleted : true
*b. Error
*
*/
@Test
fun `On Delete Note Successful`() = runBlocking {
val testNote = getNote()
every { locator.localAnon } returns localNoteRepo
coEvery { localNoteRepo.deleteNote(testNote) } returns Result.build {
Unit
}
//2 Call the Unit to be tested
val result: Result = anonSource.deleteNote(
testNote,
locator
)
verify { locator.localAnon }
coVerify { localNoteRepo.deleteNote(testNote) }
if (result is Result.Value) assertTrue(true)
else assertTrue { false }
}
/**
* b:
*/
@Test
fun `On Delete Note Error`() = runBlocking {
val testNote = getNote()
every { locator.localAnon } returns localNoteRepo
coEvery { localNoteRepo.deleteNote(testNote) } returns Result.build {
throw SpaceNotesError.LocalIOException
}
//2 Call the Unit to be tested
val result: Result = anonSource.deleteNote(
testNote,
locator
)
verify { locator.localAnon }
coVerify { localNoteRepo.deleteNote(testNote) }
assertTrue(result is Result.Error)
}
@AfterEach
fun confirm() {
confirmVerified(
locator,
localNoteRepo
)
}
}
================================================
FILE: domain/src/test/java/com/wiseassblog/domain/PublicNoteSourceTest.kt
================================================
package com.wiseassblog.domain
import com.wiseassblog.domain.domainmodel.*
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.interactor.PublicNoteSource
import com.wiseassblog.domain.repository.IPublicNoteRepository
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PublicNoteSourceTest {
val remote: IPublicNoteRepository = mockk()
val locator: NoteServiceLocator = mockk()
val source = PublicNoteSource()
fun getNote(creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: User? = User(
"8675309",
"Ajahn Chah",
""
)
) = Note(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator
)
/**
* Notes come from a single source, without any other shit involved.
*
* a: Notes returned
* b: error returned
*
* a:
* 1. Ask remote for notes: Notes
*
*/
@Test
fun `On Get Notes`() = runBlocking {
val testList = listOf(getNote(), getNote())
every { locator.remotePublic } returns remote
coEvery { remote.getNotes() } returns Result.build { testList }
val result = source.getNotes(locator)
coVerify { remote.getNotes() }
if (result is Result.Value) assertEquals(result.value, testList)
else assertTrue { false }
}
/**
*b:
*1. Ask remote for notes: error
*/
@Test
fun `On Get Notes error`() = runBlocking {
every { locator.remotePublic } returns remote
coEvery { remote.getNotes() } returns Result.build { throw SpaceNotesError.RemoteIOException }
val result = source.getNotes(locator)
coVerify { remote.getNotes() }
assertTrue { result is Result.Error }
}
/**
* Get a note by an id
*
* a: Note returned
* b: error returned
*
* a:
* 1. Ask remote for note: Note
*
*/
@Test
fun `On Get Note`() = runBlocking {
val testNote = getNote()
every { locator.remotePublic } returns remote
coEvery { remote.getNote(testNote.creator!!.uid) } returns Result.build { testNote }
val result = source.getNoteById(testNote.creator!!.uid,locator)
coVerify { remote.getNote(testNote.creator!!.uid) }
if (result is Result.Value) assertEquals(result.value, testNote)
else assertTrue { false }
}
/**
*b:
*1. Ask remote for notes: error
*/
@Test
fun `On Get Note error`() = runBlocking {
val testNote = getNote()
every { locator.remotePublic } returns remote
coEvery { remote.getNote(testNote.creator!!.uid) } returns Result.build { throw SpaceNotesError.RemoteIOException }
val result = source.getNoteById(testNote.creator!!.uid,locator)
coVerify { remote.getNote(testNote.creator!!.uid) }
assertTrue { result is Result.Error }
}
}
================================================
FILE: domain/src/test/java/com/wiseassblog/domain/RegisteredNoteSourceTest.kt
================================================
package com.wiseassblog.domain
import com.wiseassblog.domain.domainmodel.*
import com.wiseassblog.domain.error.SpaceNotesError
import com.wiseassblog.domain.interactor.RegisteredNoteSource
import com.wiseassblog.domain.repository.IRemoteNoteRepository
import com.wiseassblog.domain.repository.ITransactionRepository
import com.wiseassblog.domain.servicelocator.NoteServiceLocator
import io.mockk.*
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Registered Note Source is for users which have authenticated via appropriate sign in functions.
* Registered users have access to:
* - A remote repository to share notes across devices, which is the source of truth for state
* - A local repository to cache the most recent snap shot of the remote data, and to store offline
* transactions to be pushed to the remote database.
*/
class RegisteredNoteSourceTest {
val source = RegisteredNoteSource()
//Stores transactions (NoteTransaction Cache) to be pushed to Remote eventually
val transactionRepository: ITransactionRepository = mockk()
//Contains Remote (SoT) and Local (State Cache)
val noteRepository: IRemoteNoteRepository = mockk()
val locator: NoteServiceLocator = mockk()
fun getNote(creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: User? = User(
"8675309",
"Ajahn Chah",
""
)
) = Note(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator
)
fun getTransaction(
creationDate: String = "28/10/2018",
contents: String = "When I understand that this glass is already broken, every moment with it becomes precious.",
upVotes: Int = 0,
imageUrl: String = "",
creator: User? = User(
"8675309",
"Ajahn Chah",
""
),
transactionType: TransactionType = TransactionType.DELETE
) = NoteTransaction(
creationDate = creationDate,
contents = contents,
upVotes = upVotes,
imageUrl = imageUrl,
creator = creator,
transactionType = transactionType
)
@BeforeEach
fun setUpRedundantMocks() {
clearAllMocks()
}
/**
* Upon requesting Notes, multiple steps must occur:
* 1. Check transactionRepo Cache for items:
* a. Empty: Proceed 3
* b. Not Empty: Proceed 2
*
* 2. Attempt to synchronize Remote with stored transactions:
* c. Successful: Delete all transactions from transactionRepo; Proceed 3
* d. Fail: Proceed 3
*
* 3. Get data from IRemoteNoteSource and return
* e. Success: return data
* f. Fail: return error
*
*
* successful communication with the remote datasource, and
*
*/
@Test
fun `On Get Notes a, e`() = runBlocking {
val testNotes = listOf(getNote(), getNote(), getNote())
every { locator.transactionReg } returns transactionRepository
every { locator.remoteReg } returns noteRepository
coEvery { transactionRepository.getTransactions() } returns Result.build {
emptyList()
}
coEvery { noteRepository.getNotes() } returns Result.build {
testNotes
}
val result = source.getNotes(locator)
coVerify { transactionRepository.getTransactions() }
coVerify { noteRepository.getNotes() }
if (result is Result.Value) assertEquals(result.value, testNotes)
else assertTrue { false }
}
/**
* b - transactions not empty
* c - successfully synchronized remote
* e - successfully returned data form remote
*/
@Test
fun `On Get Notes b, c, e`() = runBlocking {
val testNotes = listOf(getNote(), getNote(), getNote())
val testTransactions = listOf(getTransaction(), getTransaction(), getTransaction())
every { locator.transactionReg } returns transactionRepository
every { locator.remoteReg } returns noteRepository
coEvery { transactionRepository.getTransactions() } returns Result.build {
testTransactions
}
coEvery { transactionRepository.deleteTransactions() } returns Result.build {
Unit
}
coEvery { noteRepository.getNotes() } returns Result.build {
testNotes
}
coEvery { noteRepository.synchronizeTransactions(testTransactions) } returns Result.build {
Unit
}
val result = source.getNotes(locator)
coVerify { transactionRepository.getTransactions() }
coVerify { noteRepository.synchronizeTransactions(testTransactions) }
coVerify { transactionRepository.deleteTransactions() }
coVerify { noteRepository.getNotes() }
if (result is Result.Value) assertEquals(result.value, testNotes)
else assertTrue { false }
}
/**
* Attempt to retrieve a note from remote repository.
* a. Success
* b. Fail
*
* a:
* 1. Request Note from Remote: success
* 2. return data
*
*/
@Test
fun `On Get Note a`() = runBlocking {
val testId = getNote().creationDate
every { locator.remoteReg } returns noteRepository
coEvery { noteRepository.getNote(testId) } returns Result.build {
getNote()
}
val result = source.getNoteById(testId, locator)
coVerify { noteRepository.getNote(testId) }
if (result is Result.Value) assertEquals(result.value, getNote())
else assertTrue { false }
}
/**
* b:
* 1. Request Note from Remote: fail
* 2. return error
*/
@Test
fun `On Get Note b`() = runBlocking {
val testId = getNote().creationDate
every { locator.remoteReg } returns noteRepository
coEvery { noteRepository.getNote(testId) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
val result = source.getNoteById(testId, locator)
coVerify { noteRepository.getNote(testId) }
assertTrue { result is Result.Error }
}
/**
* Attempt to delete a note from remote repository. Failing that, store a transaction object
* in transaction database. Failing that, return error.
* a. Success
* b. Delete Fail
* c. Transaction Fail
*
* a:
* 1. Delete Note from Remote: success
* 2. return Success
*
*/
@Test
fun `On Delete Note a`() = runBlocking {
val testNote = getNote()
every { locator.remoteReg } returns noteRepository
coEvery { noteRepository.deleteNote(testNote) } returns Result.build {
Unit
}
val result = source.deleteNote(testNote, locator)
coVerify { noteRepository.deleteNote(testNote) }
if (result is Result.Value) {
//assert the value as being "true"
assertTrue { true }
} else {
assertTrue { false }
}
}
/**
* b:
* 1. Delete Note from Remote: fail
* 2. Map to NoteTransaction and store in transactionRepository: success
* 3. return Success
*/
@Test
fun `On Delete Note b`() = runBlocking {
val testNote = getNote()
val testTransaction = getNote().toTransaction(TransactionType.DELETE)
every { locator.remoteReg } returns noteRepository
every { locator.transactionReg } returns transactionRepository
coEvery { noteRepository.deleteNote(testNote) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
coEvery { transactionRepository.updateTransactions(testTransaction) } returns Result.build {
Unit
}
val result = source.deleteNote(testNote, locator)
coVerify { noteRepository.deleteNote(testNote) }
coVerify { transactionRepository.updateTransactions(testTransaction) }
if (result is Result.Value) {
//assert the value as being "false"
assertTrue { true }
} else {
assertTrue { false }
}
}
/**
* c:
* 1. Delete Note from Remote: fail
* 2. Map to NoteTransaction and store in transactionRepository: fail
* 3. return error
*/
@Test
fun `On Delete Note c`() = runBlocking {
val testNote = getNote()
val testTransaction = getNote().toTransaction(TransactionType.DELETE)
every { locator.remoteReg } returns noteRepository
every { locator.transactionReg } returns transactionRepository
coEvery { noteRepository.deleteNote(testNote) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
coEvery { transactionRepository.updateTransactions(testTransaction) } returns Result.build {
throw SpaceNotesError.TransactionIOException
}
val result = source.deleteNote(testNote, locator)
coVerify { noteRepository.deleteNote(testNote) }
coVerify { transactionRepository.updateTransactions(testTransaction) }
assertTrue { result is Result.Error }
}
/**
* Attempt to update a note in remote repository. Failing that, store a transaction object
* in transaction database. Failing that, return error.
* a. Success
* b. Update Fail
* c. Transaction Fail
*
* a:
* 1. Update Note from Remote: success
* 2. return Success
*
*/
@Test
fun `On Update Note a`() = runBlocking {
val testNote = getNote()
every { locator.remoteReg } returns noteRepository
coEvery { noteRepository.updateNote(testNote) } returns Result.build {
Unit
}
val result = source.updateNote(testNote, locator)
coVerify { noteRepository.updateNote(testNote) }
if (result is Result.Value) {
//assert the value as being "true"
assertTrue { true }
} else {
assertTrue { false }
}
}
/**
* b:
* 1. Delete Note from Remote: fail
* 2. Map to NoteTransaction and store in transactionRepository: success
* 3. return Success
*/
@Test
fun `On Update Note b`() = runBlocking {
val testNote = getNote()
val testTransaction = getNote().toTransaction(TransactionType.UPDATE)
every { locator.remoteReg } returns noteRepository
every { locator.transactionReg } returns transactionRepository
coEvery { noteRepository.updateNote(testNote) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
coEvery { transactionRepository.updateTransactions(testTransaction) } returns Result.build {
Unit
}
val result = source.updateNote(testNote, locator)
coVerify { noteRepository.updateNote(testNote) }
coVerify { transactionRepository.updateTransactions(testTransaction) }
if (result is Result.Value) {
//assert the value as being "false"
assertTrue { true }
} else {
assertTrue { false }
}
}
/**
* c:
* 1. Delete Note from Remote: fail
* 2. Map to NoteTransaction and store in transactionRepository: fail
* 3. return error
*/
@Test
fun `On Update Note c`() = runBlocking {
val testNote = getNote()
val testTransaction = getNote().toTransaction(TransactionType.UPDATE)
every { locator.remoteReg } returns noteRepository
every { locator.transactionReg } returns transactionRepository
coEvery { noteRepository.updateNote(testNote) } returns Result.build {
throw SpaceNotesError.RemoteIOException
}
coEvery { transactionRepository.updateTransactions(testTransaction) } returns Result.build {
throw SpaceNotesError.TransactionIOException
}
val result = source.updateNote(testNote, locator)
coVerify { noteRepository.updateNote(testNote) }
coVerify { transactionRepository.updateTransactions(testTransaction) }
assertTrue { result is Result.Error }
}
@AfterEach
fun confirm() {
excludeRecords {
locator.transactionReg
locator.remoteReg
}
confirmVerified(
transactionRepository,
noteRepository,
locator
)
}
}
================================================
FILE: gradle/wrapper/gradle-wrapper.properties
================================================
#Wed Oct 24 14:24:13 PDT 2018
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-all.zip
================================================
FILE: gradle.properties
================================================
# 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
android.useAndroidX = true
android.enableJetifier = true
================================================
FILE: gradlew
================================================
#!/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" "$@"
================================================
FILE: gradlew.bat
================================================
@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
================================================
FILE: settings.gradle
================================================
include ':app', ':data', ':domain'
================================================
FILE: versions.gradle
================================================
/**
*Source largely taken from this OS repo: https://github.com/googlesamples/android-architecture-components/blob/master/GithubBrowserSample/versions.gradle
* Thank you to everyone involved in this awesome OS project!
*
* In this file, we basically build a tree of deps in a very DRY way.
**/
//ext is a way to add extra data key/value pairs to a gradle domain object. Since it has no prefix,
//it is short hand for project.ext in this case.
//[:] is groovy syntax for creating a "Map" Object, which is a collection of key/value pairs
//create a map of key/value pairs, called deps (deps)
ext.deps = [:]
//def means we're making a local variable. We'll use this map to build our deps key/value
//pairs below
def versions = [:]
versions.lifecycle = "2.0.0-rc01"
versions.junit = "5.1.1"
versions.room = "2.1.0-alpha01"
versions.navigation = "1.0.0-alpha06"
versions.espresso = "3.0.1"
versions.mockito = "2.13.0"
versions.dagger = "2.15"
versions.support_test = "1.0.1"
versions.design = "1.0.0-rc01"
versions.appcompat = "1.0.0"
versions.ktx_fragment = "1.0.0"
versions.rec_view = "1.0.0"
versions.firebase_auth = "16.1.0"
versions.firebase_core = "16.0.6"
versions.firebase_firestore = "17.1.4"
versions.play_services_auth = "16.0.1"
versions.constraint_layout = "2.0.0-alpha2"
versions.coroutine_version = "1.0.1"
versions.android_gradle_plugin = "3.2.1"
versions.kotlin = "1.3.0-rc-190"
def deps = [:]
def play_services = [:]
play_services.auth = "com.google.android.gms:play-services-auth:$versions.play_services_auth"
deps.play_services = play_services
def android = [:]
android.appcompat = "androidx.appcompat:appcompat:$versions.appcompat"
android.fragment = "androidx.fragment:fragment:$versions.appcompat"
android.recyclerview = "androidx.recyclerview:recyclerview:$versions.appcompat"
android.design = "com.google.android.material:material:$versions.design"
android.constraint_layout = "androidx.constraintlayout:constraintlayout:$versions.constraint_layout"
android.lifecycle_extensions = "androidx.lifecycle:lifecycle-extensions:$versions.lifecycle"
android.ktx_fragment = "androidx.fragment:fragment-ktx:$versions.ktx_fragment"
deps.android = android
def room = [:]
room.runtime = "androidx.room:room-runtime:$versions.room"
room.compiler = "androidx.room:room-compiler:$versions.room"
deps.room = room
def firebase = [:]
firebase.core = "com.google.firebase:firebase-core:$versions.firebase_core"
firebase.auth = "com.google.firebase:firebase-auth:$versions.firebase_auth"
firebase.firestore = "com.google.firebase:firebase-firestore:$versions.firebase_firestore"
deps.firebase = firebase
def test = [:]
test.junit = "org.junit.jupiter:junit-jupiter-api:$versions.junit"
test.jupiter_engine = "org.junit.jupiter:junit-jupiter-engine:$versions.junit"
test.vintage_engine = "org.junit.vintage:junit-vintage-engine:$versions.junit"
test.kotlin_junit = "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin"
test.mockk = "io.mockk:mockk:1.9"
deps.test = test
def kotlin = [:]
kotlin.kotlin_jre = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin"
kotlin.kotlin_gradle_plugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin"
kotlin.coroutines_core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutine_version"
kotlin.coroutines_android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutine_version"
deps.kotlin = kotlin
def build_versions = [:]
build_versions.min_sdk = 21
build_versions.target_sdk = 28
ext.build_versions = build_versions
deps.android_gradle_plugin = "com.android.tools.build:gradle:$versions.android_gradle_plugin"
ext.deps = deps
def addRepos(RepositoryHandler handler) {
handler.google()
handler.jcenter()
handler.maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }
handler.maven { url 'https://dl.bintray.com/kotlin/kotlin-eap' }
}
ext.addRepos = this.&addRepos