mirror of
https://github.com/afollestad/nock-nock.git
synced 2025-04-21 20:15:19 +00:00
Move majority of MainActivity business logic to MainPresenter, write unit test
This commit is contained in:
parent
b36b41ca9d
commit
f87e1438d2
8 changed files with 315 additions and 101 deletions
|
@ -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'
|
|
@ -27,6 +27,7 @@ import javax.inject.Singleton
|
|||
@Component(
|
||||
modules = [
|
||||
MainModule::class,
|
||||
MainBindModule::class,
|
||||
EngineModule::class,
|
||||
NotificationsModule::class,
|
||||
UtilitiesModule::class
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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<ServerModel>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
119
app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt
Normal file
119
app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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])
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue