From f87e1438d2bfd4ff4c05dc84b8f6e224ccc2e251 Mon Sep 17 00:00:00 2001 From: Aidan Follestad Date: Sat, 1 Dec 2018 00:05:14 -0800 Subject: [PATCH] Move majority of MainActivity business logic to MainPresenter, write unit test --- app/build.gradle | 5 + .../afollestad/nocknock/di/AppComponent.kt | 1 + .../afollestad/nocknock/di/MainBindModule.kt | 23 +++ .../nocknock/presenters/MainPresenter.kt | 104 ++++++++++++++ .../nocknock/presenters/MainView.kt | 25 ++++ .../afollestad/nocknock/ui/MainActivity.kt | 135 +++++------------- .../afollestad/nocknock/MainPresenterTest.kt | 119 +++++++++++++++ .../nocknock/utilities/ext/CoroutineExt.kt | 4 +- 8 files changed, 315 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt create mode 100644 app/src/main/java/com/afollestad/nocknock/presenters/MainPresenter.kt create mode 100644 app/src/main/java/com/afollestad/nocknock/presenters/MainView.kt create mode 100644 app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt diff --git a/app/build.gradle b/app/build.gradle index dd87fe6..d9184e1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,6 +34,11 @@ dependencies { kapt 'com.google.dagger:dagger-compiler:' + versions.dagger 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' \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt index 84534a9..703363d 100644 --- a/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt +++ b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt @@ -27,6 +27,7 @@ import javax.inject.Singleton @Component( modules = [ MainModule::class, + MainBindModule::class, EngineModule::class, NotificationsModule::class, UtilitiesModule::class diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt b/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt new file mode 100644 index 0000000..e34589e --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt @@ -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 +} diff --git a/app/src/main/java/com/afollestad/nocknock/presenters/MainPresenter.kt b/app/src/main/java/com/afollestad/nocknock/presenters/MainPresenter.kt new file mode 100644 index 0000000..a8c0009 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/presenters/MainPresenter.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/presenters/MainView.kt b/app/src/main/java/com/afollestad/nocknock/presenters/MainView.kt new file mode 100644 index 0000000..bc3ed8d --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/presenters/MainView.kt @@ -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) + + fun updateModel(model: ServerModel) + + fun onSiteDeleted(model: ServerModel) + + fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt index 73deb4b..48ce26c 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt @@ -12,76 +12,51 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Bundle -import android.util.Log import androidx.appcompat.app.AppCompatActivity -import androidx.core.text.HtmlCompat 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.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItems -import com.afollestad.nocknock.BuildConfig import com.afollestad.nocknock.R import com.afollestad.nocknock.adapter.ServerAdapter import com.afollestad.nocknock.data.ServerModel 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.KEY_UPDATE_MODEL -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager -import com.afollestad.nocknock.notifications.NockNotificationManager +import com.afollestad.nocknock.presenters.MainPresenter +import com.afollestad.nocknock.presenters.MainView +import com.afollestad.nocknock.utilities.ext.ScopeReceiver import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver import com.afollestad.nocknock.utilities.ext.scopeWhileAttached -import com.afollestad.nocknock.viewcomponents.ext.show import com.afollestad.nocknock.viewcomponents.ext.showOrHide import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.list import kotlinx.android.synthetic.main.activity_main.rootView import kotlinx.android.synthetic.main.activity_main.toolbar 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 kotlin.coroutines.CoroutineContext /** @author Aidan Follestad (afollestad) */ -class MainActivity : AppCompatActivity() { +class MainActivity : AppCompatActivity(), MainView { companion object { private const val ADD_SITE_RQ = 6969 private const val VIEW_SITE_RQ = 6923 - - private fun log(message: String) { - if (BuildConfig.DEBUG) { - Log.d("MainActivity", message) - } - } } private val intentReceiver = object : BroadcastReceiver() { override fun onReceive( context: Context, intent: 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}") - } - } + ) = presenter.onBroadcast(intent) } - @Inject lateinit var serverModelStore: ServerModelStore - @Inject lateinit var notificationManager: NockNotificationManager - @Inject lateinit var checkStatusManager: CheckStatusManager + @Inject lateinit var presenter: MainPresenter private lateinit var adapter: ServerAdapter @@ -112,16 +87,7 @@ class MainActivity : AppCompatActivity() { ) } - notificationManager.createChannels() - ensureCheckJobs() - } - - private fun ensureCheckJobs() { - rootView.scopeWhileAttached(IO) { - launch(coroutineContext) { - checkStatusManager.ensureScheduledChecks() - } - } + presenter.takeView(this) } override fun onResume() { @@ -130,9 +96,7 @@ class MainActivity : AppCompatActivity() { addAction(ACTION_STATUS_UPDATE) } safeRegisterReceiver(intentReceiver, filter) - - notificationManager.cancelStatusNotifications() - refreshModels() + presenter.resume() } override fun onPause() { @@ -140,29 +104,28 @@ class MainActivity : AppCompatActivity() { safeUnregisterReceiver(intentReceiver) } - private fun refreshModels() { - adapter.clear() - emptyText.show() - rootView.scopeWhileAttached(Main) { - launch(coroutineContext) { - val models = async(IO) { serverModelStore.get() }.await() - adapter.set(models) - emptyText.showOrHide(adapter.itemCount == 0) - } - } + override fun onDestroy() { + presenter.dropView() + super.onDestroy() } - override fun onActivityResult( - requestCode: Int, - resultCode: Int, - data: Intent? - ) { - super.onActivityResult(requestCode, resultCode, data) - if (resultCode == RESULT_OK) { - refreshModels() - } + override fun setModels(models: List) { + adapter.set(models) + emptyText.showOrHide(models.isEmpty()) } + 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( model: ServerModel, longClick: Boolean @@ -172,20 +135,8 @@ class MainActivity : AppCompatActivity() { title(R.string.options) listItems(R.array.site_long_options) { _, i, _ -> when (i) { - 0 -> { - checkStatusManager.scheduleCheck( - site = model, - rightNow = true, - cancelPrevious = true - ) - } - 1 -> { - maybeRemoveSite(model) { - adapter.remove(model) - emptyText.showOrHide(adapter.itemCount == 0) - } - } - else -> throw IllegalStateException("Unexpected index: $i") + 0 -> presenter.refreshSite(model) + 1 -> maybeRemoveSite(model) } } } @@ -199,35 +150,19 @@ class MainActivity : AppCompatActivity() { ) } - private fun maybeRemoveSite( - model: ServerModel, - onRemoved: (() -> Unit)? - ) { + private fun maybeRemoveSite(model: ServerModel) { MaterialDialog(this).show { title(R.string.remove_site) message( - text = HtmlCompat.fromHtml( - context.getString(R.string.remove_site_prompt, model.name), FROM_HTML_MODE_LEGACY + text = fromHtml( + context.getString(R.string.remove_site_prompt, model.name), + FROM_HTML_MODE_LEGACY ) ) positiveButton(R.string.remove) { - checkStatusManager.cancelCheck(model) - notificationManager.cancelStatusNotification(model) - performRemoveSite(model, onRemoved) + presenter.removeSite(model) } 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() - } - } - } } diff --git a/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt new file mode 100644 index 0000000..afe122f --- /dev/null +++ b/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt @@ -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() + private val notificationManager = mock() + private val checkStatusManager = mock() + private val view = mock() + + private val presenter = RealMainPresenter( + serverModelStore, + notificationManager, + checkStatusManager + ) + + @Before fun setup() { + doAnswer { + val exec = it.getArgument(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>() + 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 + } + } +} diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt index cdf2336..1426624 100644 --- a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt +++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt @@ -10,9 +10,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlin.coroutines.CoroutineContext +typealias ScopeReceiver = CoroutineScope.() -> Unit + fun View.scopeWhileAttached( context: CoroutineContext, - exec: CoroutineScope.() -> Unit + exec: ScopeReceiver ) { val job = Job(context[Job])