Move majority of MainActivity business logic to MainPresenter, write unit test

This commit is contained in:
Aidan Follestad 2018-12-01 00:05:14 -08:00
commit f87e1438d2
8 changed files with 315 additions and 101 deletions

View file

@ -34,6 +34,11 @@ dependencies {
kapt 'com.google.dagger:dagger-compiler:' + versions.dagger kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
testImplementation 'junit:junit:' + versions.junit
testImplementation 'org.mockito:mockito-core:' + versions.mockito
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin
testImplementation 'com.google.truth:truth:' + versions.truth
} }
apply from: '../spotless.gradle' apply from: '../spotless.gradle'

View file

@ -27,6 +27,7 @@ import javax.inject.Singleton
@Component( @Component(
modules = [ modules = [
MainModule::class, MainModule::class,
MainBindModule::class,
EngineModule::class, EngineModule::class,
NotificationsModule::class, NotificationsModule::class,
UtilitiesModule::class UtilitiesModule::class

View file

@ -0,0 +1,23 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.di
import com.afollestad.nocknock.presenters.MainPresenter
import com.afollestad.nocknock.presenters.RealMainPresenter
import dagger.Binds
import dagger.Module
import javax.inject.Singleton
/** @author Aidan Follestad (afollestad) */
@Module
abstract class MainBindModule {
@Binds
@Singleton
abstract fun provideMainPresenter(
presenter: RealMainPresenter
): MainPresenter
}

View file

@ -0,0 +1,104 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.presenters
import android.content.Intent
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject
/** @author Aidan Follestad (afollestad) */
interface MainPresenter {
fun takeView(view: MainView)
fun onBroadcast(intent: Intent)
fun resume()
fun refreshSite(site: ServerModel)
fun removeSite(site: ServerModel)
fun dropView()
}
/** @author Aidan Follestad (afollestad) */
class RealMainPresenter @Inject constructor(
private val serverModelStore: ServerModelStore,
private val notificationManager: NockNotificationManager,
private val checkStatusManager: CheckStatusManager
) : MainPresenter {
private var view: MainView? = null
override fun takeView(view: MainView) {
this.view = view
notificationManager.createChannels()
ensureCheckJobs()
}
override fun onBroadcast(intent: Intent) {
if (intent.action == ACTION_STATUS_UPDATE) {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
view?.updateModel(model)
}
}
override fun resume() {
notificationManager.cancelStatusNotifications()
view?.run {
setModels(listOf())
scopeWhileAttached(Main) {
launch(coroutineContext) {
val models = async(IO) {
serverModelStore.get()
}
setModels(models.await())
}
}
}
}
override fun refreshSite(site: ServerModel) {
checkStatusManager.scheduleCheck(
site = site,
rightNow = true,
cancelPrevious = true
)
}
override fun removeSite(site: ServerModel) {
checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
view?.scopeWhileAttached(Main) {
launch(coroutineContext) {
async(IO) { serverModelStore.delete(site) }.await()
view?.onSiteDeleted(site)
}
}
}
override fun dropView() {
view = null
}
private fun ensureCheckJobs() {
view?.scopeWhileAttached(IO) {
launch(coroutineContext) {
checkStatusManager.ensureScheduledChecks()
}
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.presenters
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (afollestad) */
interface MainView {
fun setModels(models: List<ServerModel>)
fun updateModel(model: ServerModel)
fun onSiteDeleted(model: ServerModel)
fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
)
}

View file

@ -12,76 +12,51 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import androidx.core.text.HtmlCompat.fromHtml
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItems import com.afollestad.materialdialogs.list.listItems
import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.ServerAdapter import com.afollestad.nocknock.adapter.ServerAdapter
import com.afollestad.nocknock.data.ServerModel import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.dialogs.AboutDialog import com.afollestad.nocknock.dialogs.AboutDialog
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL import com.afollestad.nocknock.presenters.MainPresenter
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager import com.afollestad.nocknock.presenters.MainView
import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.show
import com.afollestad.nocknock.viewcomponents.ext.showOrHide import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list import kotlinx.android.synthetic.main.activity_main.list
import kotlinx.android.synthetic.main.activity_main.rootView import kotlinx.android.synthetic.main.activity_main.rootView
import kotlinx.android.synthetic.main.activity_main.toolbar import kotlinx.android.synthetic.main.activity_main.toolbar
import kotlinx.android.synthetic.main.include_empty_view.emptyText import kotlinx.android.synthetic.main.include_empty_view.emptyText
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (afollestad) */ /** @author Aidan Follestad (afollestad) */
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity(), MainView {
companion object { companion object {
private const val ADD_SITE_RQ = 6969 private const val ADD_SITE_RQ = 6969
private const val VIEW_SITE_RQ = 6923 private const val VIEW_SITE_RQ = 6923
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("MainActivity", message)
}
}
} }
private val intentReceiver = object : BroadcastReceiver() { private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive( override fun onReceive(
context: Context, context: Context,
intent: Intent intent: Intent
) { ) = presenter.onBroadcast(intent)
log("Received broadcast ${intent.action}")
when (intent.action) {
ACTION_STATUS_UPDATE -> {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
log("Received model update: $model")
list.post { adapter.update(model) }
}
else -> throw IllegalStateException("Unexpected intent: ${intent.action}")
}
}
} }
@Inject lateinit var serverModelStore: ServerModelStore @Inject lateinit var presenter: MainPresenter
@Inject lateinit var notificationManager: NockNotificationManager
@Inject lateinit var checkStatusManager: CheckStatusManager
private lateinit var adapter: ServerAdapter private lateinit var adapter: ServerAdapter
@ -112,16 +87,7 @@ class MainActivity : AppCompatActivity() {
) )
} }
notificationManager.createChannels() presenter.takeView(this)
ensureCheckJobs()
}
private fun ensureCheckJobs() {
rootView.scopeWhileAttached(IO) {
launch(coroutineContext) {
checkStatusManager.ensureScheduledChecks()
}
}
} }
override fun onResume() { override fun onResume() {
@ -130,9 +96,7 @@ class MainActivity : AppCompatActivity() {
addAction(ACTION_STATUS_UPDATE) addAction(ACTION_STATUS_UPDATE)
} }
safeRegisterReceiver(intentReceiver, filter) safeRegisterReceiver(intentReceiver, filter)
presenter.resume()
notificationManager.cancelStatusNotifications()
refreshModels()
} }
override fun onPause() { override fun onPause() {
@ -140,29 +104,28 @@ class MainActivity : AppCompatActivity() {
safeUnregisterReceiver(intentReceiver) safeUnregisterReceiver(intentReceiver)
} }
private fun refreshModels() { override fun onDestroy() {
adapter.clear() presenter.dropView()
emptyText.show() super.onDestroy()
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
val models = async(IO) { serverModelStore.get() }.await()
adapter.set(models)
emptyText.showOrHide(adapter.itemCount == 0)
}
}
} }
override fun onActivityResult( override fun setModels(models: List<ServerModel>) {
requestCode: Int, adapter.set(models)
resultCode: Int, emptyText.showOrHide(models.isEmpty())
data: Intent?
) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK) {
refreshModels()
}
} }
override fun updateModel(model: ServerModel) = adapter.update(model)
override fun onSiteDeleted(model: ServerModel) {
adapter.remove(model)
emptyText.showOrHide(adapter.itemCount == 0)
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
private fun onSiteSelected( private fun onSiteSelected(
model: ServerModel, model: ServerModel,
longClick: Boolean longClick: Boolean
@ -172,20 +135,8 @@ class MainActivity : AppCompatActivity() {
title(R.string.options) title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ -> listItems(R.array.site_long_options) { _, i, _ ->
when (i) { when (i) {
0 -> { 0 -> presenter.refreshSite(model)
checkStatusManager.scheduleCheck( 1 -> maybeRemoveSite(model)
site = model,
rightNow = true,
cancelPrevious = true
)
}
1 -> {
maybeRemoveSite(model) {
adapter.remove(model)
emptyText.showOrHide(adapter.itemCount == 0)
}
}
else -> throw IllegalStateException("Unexpected index: $i")
} }
} }
} }
@ -199,35 +150,19 @@ class MainActivity : AppCompatActivity() {
) )
} }
private fun maybeRemoveSite( private fun maybeRemoveSite(model: ServerModel) {
model: ServerModel,
onRemoved: (() -> Unit)?
) {
MaterialDialog(this).show { MaterialDialog(this).show {
title(R.string.remove_site) title(R.string.remove_site)
message( message(
text = HtmlCompat.fromHtml( text = fromHtml(
context.getString(R.string.remove_site_prompt, model.name), FROM_HTML_MODE_LEGACY context.getString(R.string.remove_site_prompt, model.name),
FROM_HTML_MODE_LEGACY
) )
) )
positiveButton(R.string.remove) { positiveButton(R.string.remove) {
checkStatusManager.cancelCheck(model) presenter.removeSite(model)
notificationManager.cancelStatusNotification(model)
performRemoveSite(model, onRemoved)
} }
negativeButton(android.R.string.cancel) negativeButton(android.R.string.cancel)
} }
} }
private fun performRemoveSite(
model: ServerModel,
onRemoved: (() -> Unit)?
) {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
async(IO) { serverModelStore.delete(model) }.await()
onRemoved?.invoke()
}
}
}
} }

View file

@ -0,0 +1,119 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock
import android.content.Intent
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.presenters.MainView
import com.afollestad.nocknock.presenters.RealMainPresenter
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class MainPresenterTest {
private val serverModelStore = mock<ServerModelStore>()
private val notificationManager = mock<NockNotificationManager>()
private val checkStatusManager = mock<CheckStatusManager>()
private val view = mock<MainView>()
private val presenter = RealMainPresenter(
serverModelStore,
notificationManager,
checkStatusManager
)
@Before fun setup() {
doAnswer {
val exec = it.getArgument<ScopeReceiver>(1)
runBlocking { exec() }
Unit
}.whenever(view)
.scopeWhileAttached(any(), any())
presenter.takeView(view)
}
@After fun destroy() {
presenter.dropView()
}
@Test fun onBroadcast() {
val badIntent = fakeIntent("Hello World")
presenter.onBroadcast(badIntent)
val model = fakeModel()
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
.doReturn(model)
presenter.onBroadcast(goodIntent)
verify(view, times(1)).updateModel(model)
}
@Test fun resume() = runBlocking {
val model = fakeModel()
whenever(serverModelStore.get()).doReturn(listOf(model))
presenter.resume()
verify(notificationManager).cancelStatusNotifications()
val modelsCaptor = argumentCaptor<List<ServerModel>>()
verify(view, times(2)).setModels(modelsCaptor.capture())
assertThat(modelsCaptor.firstValue).isEmpty()
assertThat(modelsCaptor.lastValue.single()).isEqualTo(model)
}
@Test fun refreshSite() {
val model = fakeModel()
presenter.refreshSite(model)
verify(checkStatusManager).scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true
)
}
@Test fun removeSite() = runBlocking {
val model = fakeModel()
presenter.removeSite(model)
verify(checkStatusManager).cancelCheck(model)
verify(notificationManager).cancelStatusNotification(model)
verify(serverModelStore).delete(model)
verify(view).onSiteDeleted(model)
}
private fun fakeModel() = ServerModel(
name = "Test",
url = "https://test.com",
validationMode = STATUS_CODE
)
private fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
}

View file

@ -10,9 +10,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
typealias ScopeReceiver = CoroutineScope.() -> Unit
fun View.scopeWhileAttached( fun View.scopeWhileAttached(
context: CoroutineContext, context: CoroutineContext,
exec: CoroutineScope.() -> Unit exec: ScopeReceiver
) { ) {
val job = Job(context[Job]) val job = Job(context[Job])