From 225434b41a17508523ed1b25cc06629fac97db1b Mon Sep 17 00:00:00 2001 From: Aidan Follestad Date: Sat, 1 Dec 2018 01:30:25 -0800 Subject: [PATCH] Move majority of add site logic to a presenter, add unit tests --- app/src/main/AndroidManifest.xml | 6 +- .../com/afollestad/nocknock/NockNockApp.kt | 6 +- .../nocknock/adapter/ServerAdapter.kt | 5 +- .../afollestad/nocknock/di/AppComponent.kt | 6 +- .../afollestad/nocknock/di/MainBindModule.kt | 12 +- .../ui/{ => addsite}/AddSiteActivity.kt | 205 +++++++-------- .../nocknock/ui/addsite/AddSitePresenter.kt | 167 ++++++++++++ .../nocknock/ui/addsite/AddSiteView.kt | 35 +++ .../nocknock/ui/{ => main}/MainActivity.kt | 26 +- .../{presenters => ui/main}/MainPresenter.kt | 12 +- .../{presenters => ui/main}/MainView.kt | 2 +- .../ui/{ => viewsite}/ViewSiteActivity.kt | 11 +- app/src/main/res/layout/activity_main.xml | 2 +- app/src/main/res/values/strings.xml | 3 + .../nocknock/AddSitePresenterTest.kt | 241 ++++++++++++++++++ .../afollestad/nocknock/MainPresenterTest.kt | 4 +- .../notifications/NockNotificationManager.kt | 2 +- .../viewcomponents/CheckIntervalLayout.kt | 4 + .../viewcomponents/JavaScriptInputLayout.kt | 11 +- .../res/layout/javascript_input_layout.xml | 11 + viewcomponents/src/main/res/values/styles.xml | 6 + 21 files changed, 622 insertions(+), 155 deletions(-) rename app/src/main/java/com/afollestad/nocknock/ui/{ => addsite}/AddSiteActivity.kt (58%) create mode 100644 app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt create mode 100644 app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt rename app/src/main/java/com/afollestad/nocknock/ui/{ => main}/MainActivity.kt (91%) rename app/src/main/java/com/afollestad/nocknock/{presenters => ui/main}/MainPresenter.kt (93%) rename app/src/main/java/com/afollestad/nocknock/{presenters => ui/main}/MainView.kt (92%) rename app/src/main/java/com/afollestad/nocknock/ui/{ => viewsite}/ViewSiteActivity.kt (98%) create mode 100644 app/src/test/java/com/afollestad/nocknock/AddSitePresenterTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f5ec28..87a00d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,7 @@ tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute"> @@ -28,14 +28,14 @@ ) { this.models.clear() - if (newModels.isEmpty()) { - return + if (!newModels.isEmpty()) { + this.models.addAll(newModels) } - this.models.addAll(newModels) notifyDataSetChanged() } 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 703363d..a21bb08 100644 --- a/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt +++ b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt @@ -13,9 +13,9 @@ import com.afollestad.nocknock.engine.EngineModule import com.afollestad.nocknock.engine.statuscheck.BootReceiver import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob import com.afollestad.nocknock.notifications.NotificationsModule -import com.afollestad.nocknock.ui.AddSiteActivity -import com.afollestad.nocknock.ui.MainActivity -import com.afollestad.nocknock.ui.ViewSiteActivity +import com.afollestad.nocknock.ui.addsite.AddSiteActivity +import com.afollestad.nocknock.ui.main.MainActivity +import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity import com.afollestad.nocknock.utilities.UtilitiesModule import dagger.BindsInstance import dagger.Component diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt b/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt index e34589e..af1833f 100644 --- a/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt +++ b/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt @@ -5,8 +5,10 @@ */ package com.afollestad.nocknock.di -import com.afollestad.nocknock.presenters.MainPresenter -import com.afollestad.nocknock.presenters.RealMainPresenter +import com.afollestad.nocknock.ui.addsite.AddSitePresenter +import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter +import com.afollestad.nocknock.ui.main.MainPresenter +import com.afollestad.nocknock.ui.main.RealMainPresenter import dagger.Binds import dagger.Module import javax.inject.Singleton @@ -20,4 +22,10 @@ abstract class MainBindModule { abstract fun provideMainPresenter( presenter: RealMainPresenter ): MainPresenter + + @Binds + @Singleton + abstract fun provideAddSitePresenter( + presenter: RealAddSitePresenter + ): AddSitePresenter } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt similarity index 58% rename from app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.kt rename to app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt index 43dd24b..7b5723e 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt @@ -3,35 +3,29 @@ * * Designed and developed by Aidan Follestad (@afollestad) */ -package com.afollestad.nocknock.ui +package com.afollestad.nocknock.ui.addsite import android.annotation.SuppressLint import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION -import android.net.Uri import android.os.Bundle -import android.util.Patterns.WEB_URL -import android.view.View import android.view.ViewAnimationUtils.createCircularReveal import android.view.animation.AccelerateInterpolator import android.view.animation.DecelerateInterpolator import android.widget.ArrayAdapter import androidx.appcompat.app.AppCompatActivity import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.WAITING import com.afollestad.nocknock.data.ValidationMode import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH import com.afollestad.nocknock.data.indexToValidationMode -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.ui.main.MainActivity +import com.afollestad.nocknock.utilities.ext.ScopeReceiver import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.onEnd import com.afollestad.nocknock.utilities.ext.scopeWhileAttached import com.afollestad.nocknock.viewcomponents.ext.conceal -import com.afollestad.nocknock.viewcomponents.ext.hide import com.afollestad.nocknock.viewcomponents.ext.onItemSelected import com.afollestad.nocknock.viewcomponents.ext.onLayout import com.afollestad.nocknock.viewcomponents.ext.show @@ -42,20 +36,15 @@ import kotlinx.android.synthetic.main.activity_addsite.doneBtn import kotlinx.android.synthetic.main.activity_addsite.inputName import kotlinx.android.synthetic.main.activity_addsite.inputUrl import kotlinx.android.synthetic.main.activity_addsite.loadingProgress -import kotlinx.android.synthetic.main.activity_addsite.nameTiLayout import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm import kotlinx.android.synthetic.main.activity_addsite.rootView import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning import kotlinx.android.synthetic.main.activity_addsite.toolbar -import kotlinx.android.synthetic.main.activity_addsite.urlTiLayout import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription -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 import kotlin.math.max import kotlin.properties.Delegates.notNull @@ -76,7 +65,7 @@ fun MainActivity.intentToAdd( } /** @author Aidan Follestad (afollestad) */ -class AddSiteActivity : AppCompatActivity(), View.OnClickListener { +class AddSiteActivity : AppCompatActivity(), AddSiteView { companion object { private const val REVEAL_DURATION = 300L @@ -84,8 +73,7 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener { private var isClosing: Boolean = false - @Inject lateinit var serverModelStore: ServerModelStore - @Inject lateinit var checkStatusManager: CheckStatusManager + @Inject lateinit var presenter: AddSitePresenter private var revealCx by notNull() private var revealCy by notNull() @@ -94,9 +82,11 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener { @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - injector().injectInto(this) + injector().injectInto(this) setContentView(R.layout.activity_addsite) + presenter.takeView(this) + toolbar.setNavigationOnClickListener { closeActivityWithReveal() } if (savedInstanceState == null) { @@ -117,25 +107,7 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener { } inputUrl.setOnFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - val inputStr = inputUrl.text - .toString() - .trim() - if (inputStr.isEmpty()) { - return@setOnFocusChangeListener - } - - val uri = Uri.parse(inputStr) - if (uri.scheme == null) { - inputUrl.setText("http://$inputStr") - textUrlWarning.hide() - } else if ("http" != uri.scheme && "https" != uri.scheme) { - textUrlWarning.show() - textUrlWarning.setText(R.string.warning_http_url) - } else { - textUrlWarning.hide() - } - } + presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText()) } val validationOptionsAdapter = ArrayAdapter( @@ -146,23 +118,91 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener { validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) responseValidationMode.adapter = validationOptionsAdapter - responseValidationMode.onItemSelected { pos -> - responseValidationSearchTerm.showOrHide(pos == 1) - scriptInputLayout.showOrHide(pos == 2) + responseValidationMode.onItemSelected(presenter::onValidationModeSelected) - validationModeDescription.setText( - when (pos) { - 0 -> R.string.validation_mode_status_desc - 1 -> R.string.validation_mode_term_desc - 2 -> R.string.validation_mode_javascript_desc - else -> throw IllegalStateException("Unknown validation mode position: $pos") - } + doneBtn.setOnClickListener { + val checkInterval = checkIntervalLayout.getSelectedCheckInterval() + val validationMode = + responseValidationMode.selectedItemPosition.indexToValidationMode() + + isClosing = true + presenter.commit( + name = inputName.trimmedText(), + url = inputUrl.trimmedText(), + checkInterval = checkInterval, + validationMode = validationMode, + validationContent = validationMode.validationContent() ) } - - doneBtn.setOnClickListener(this) } + override fun onDestroy() { + presenter.dropView() + super.onDestroy() + } + + override fun setLoading() = loadingProgress.setLoading() + + override fun setDoneLoading() = loadingProgress.setDone() + + override fun showOrHideUrlSchemeWarning(show: Boolean) { + textUrlWarning.showOrHide(show) + if (show) { + textUrlWarning.setText(R.string.warning_http_url) + } + } + + override fun showOrHideValidationSearchTerm(show: Boolean) = + responseValidationSearchTerm.showOrHide(show) + + override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show) + + override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res) + + override fun setInputErrors(errors: InputErrors) { + isClosing = false + inputName.error = if (errors.name != null) { + getString(errors.name!!) + } else { + null + } + inputUrl.error = if (errors.url != null) { + getString(errors.url!!) + } else { + null + } + checkIntervalLayout.setError( + if (errors.checkInterval != null) { + getString(errors.checkInterval!!) + } else { + null + } + ) + responseValidationSearchTerm.error = if (errors.termSearch != null) { + getString(errors.termSearch!!) + } else { + null + } + scriptInputLayout.setError( + if (errors.javaScript != null) { + getString(errors.javaScript!!) + } else { + null + } + ) + } + + override fun onSiteAdded() { + setResult(RESULT_OK) + finish() + overridePendingTransition(R.anim.fade_out, R.anim.fade_out) + } + + override fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) = rootView.scopeWhileAttached(context, exec) + private fun circularRevealActivity() { val circularReveal = createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius) @@ -190,71 +230,6 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener { } } - // Done button - override fun onClick(view: View) { - isClosing = true - var newModel = ServerModel( - name = inputName.trimmedText(), - url = inputUrl.trimmedText(), - status = WAITING, - validationMode = STATUS_CODE - ) - - if (newModel.name.isEmpty()) { - nameTiLayout.error = getString(R.string.please_enter_name) - isClosing = false - return - } else { - nameTiLayout.error = null - } - - if (newModel.url.isEmpty()) { - urlTiLayout.error = getString(R.string.please_enter_url) - isClosing = false - return - } else { - urlTiLayout.error = null - if (!WEB_URL.matcher(newModel.url).find()) { - urlTiLayout.error = getString(R.string.please_enter_valid_url) - isClosing = false - return - } else { - val uri = Uri.parse(newModel.url) - if (uri.scheme == null) { - newModel = newModel.copy(url = "http://${newModel.url}") - } - } - } - - val selectedCheckInterval = checkIntervalLayout.getSelectedCheckInterval() - val selectedValidationMode = - responseValidationMode.selectedItemPosition.indexToValidationMode() - - newModel = newModel.copy( - checkInterval = selectedCheckInterval, - validationMode = selectedValidationMode, - validationContent = selectedValidationMode.validationContent() - ) - - rootView.scopeWhileAttached(Main) { - launch(coroutineContext) { - loadingProgress.setLoading() - val storedModel = async(IO) { serverModelStore.put(newModel) }.await() - - checkStatusManager.scheduleCheck( - site = storedModel, - rightNow = true, - cancelPrevious = true - ) - loadingProgress.setDone() - - setResult(RESULT_OK) - finish() - overridePendingTransition(R.anim.fade_out, R.anim.fade_out) - } - } - } - override fun onBackPressed() = closeActivityWithReveal() private fun ValidationMode.validationContent() = when (this) { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt new file mode 100644 index 0000000..5849799 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt @@ -0,0 +1,167 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.addsite + +import androidx.annotation.CheckResult +import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.ServerStatus.WAITING +import com.afollestad.nocknock.data.ValidationMode +import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.engine.db.ServerModelStore +import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import okhttp3.HttpUrl +import javax.inject.Inject + +/** @author Aidan Follestad (afollestad) */ +data class InputErrors( + var name: Int? = null, + var url: Int? = null, + var checkInterval: Int? = null, + var termSearch: Int? = null, + var javaScript: Int? = null +) { + @CheckResult fun any(): Boolean { + return name != null || url != null || checkInterval != null || + termSearch != null || javaScript != null + } +} + +/** @author Aidan Follestad (afollestad) */ +interface AddSitePresenter { + + fun takeView(view: AddSiteView) + + fun onUrlInputFocusChange( + focused: Boolean, + content: String + ) + + fun onValidationModeSelected(index: Int) + + fun commit( + name: String, + url: String, + checkInterval: Long, + validationMode: ValidationMode, + validationContent: String? + ) + + fun dropView() +} + +/** @author Aidan Follestad (afollestad) */ +class RealAddSitePresenter @Inject constructor( + private val serverModelStore: ServerModelStore, + private val checkStatusManager: CheckStatusManager +) : AddSitePresenter { + + private var view: AddSiteView? = null + + override fun takeView(view: AddSiteView) { + this.view = view + } + + override fun onUrlInputFocusChange( + focused: Boolean, + content: String + ) { + if (content.isEmpty() || focused) { + return + } + val url = HttpUrl.parse(content) + if (url == null || + (url.scheme() != "http" && + url.scheme() != "https") + ) { + view?.showOrHideUrlSchemeWarning(true) + } else { + view?.showOrHideUrlSchemeWarning(false) + } + } + + override fun onValidationModeSelected(index: Int) = with(view!!) { + showOrHideValidationSearchTerm(index == 1) + showOrHideScriptInput(index == 2) + setValidationModeDescription( + when (index) { + 0 -> R.string.validation_mode_status_desc + 1 -> R.string.validation_mode_term_desc + 2 -> R.string.validation_mode_javascript_desc + else -> throw IllegalStateException("Unknown validation mode position: $index") + } + ) + } + + override fun commit( + name: String, + url: String, + checkInterval: Long, + validationMode: ValidationMode, + validationContent: String? + ) { + val inputErrors = InputErrors() + + if (name.isEmpty()) { + inputErrors.name = R.string.please_enter_name + } + if (url.isEmpty()) { + inputErrors.url = R.string.please_enter_url + } else if (HttpUrl.parse(url) == null) { + inputErrors.url = R.string.please_enter_valid_url + } + if (checkInterval <= 0) { + inputErrors.checkInterval = R.string.please_enter_check_interval + } + if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) { + inputErrors.termSearch = R.string.please_enter_search_term + } else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) { + inputErrors.javaScript = R.string.please_enter_javaScript + } + + if (inputErrors.any()) { + view?.setInputErrors(inputErrors) + return + } + + val newModel = ServerModel( + name = name, + url = url, + status = WAITING, + checkInterval = checkInterval, + validationMode = validationMode, + validationContent = validationContent + ) + + with(view!!) { + scopeWhileAttached(Main) { + launch(coroutineContext) { + setLoading() + val storedModel = async(IO) { + serverModelStore.put(newModel) + }.await() + + checkStatusManager.scheduleCheck( + site = storedModel, + rightNow = true, + cancelPrevious = true + ) + setDoneLoading() + onSiteAdded() + } + } + } + } + + override fun dropView() { + view = null + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt new file mode 100644 index 0000000..6e3b3cd --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt @@ -0,0 +1,35 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.addsite + +import androidx.annotation.StringRes +import com.afollestad.nocknock.utilities.ext.ScopeReceiver +import kotlin.coroutines.CoroutineContext + +/** @author Aidan Follestad (afollestad) */ +interface AddSiteView { + + fun setLoading() + + fun setDoneLoading() + + fun showOrHideUrlSchemeWarning(show: Boolean) + + fun showOrHideValidationSearchTerm(show: Boolean) + + fun showOrHideScriptInput(show: Boolean) + + fun setValidationModeDescription(@StringRes res: Int) + + fun setInputErrors(errors: InputErrors) + + fun onSiteAdded() + + 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/main/MainActivity.kt similarity index 91% rename from app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt rename to app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt index 48ce26c..b2ea8f4 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt @@ -3,7 +3,7 @@ * * Designed and developed by Aidan Follestad (@afollestad) */ -package com.afollestad.nocknock.ui +package com.afollestad.nocknock.ui.main import android.annotation.SuppressLint import android.app.ActivityOptions.makeSceneTransitionAnimation @@ -25,8 +25,8 @@ import com.afollestad.nocknock.adapter.ServerAdapter import com.afollestad.nocknock.data.ServerModel import com.afollestad.nocknock.dialogs.AboutDialog import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.presenters.MainPresenter -import com.afollestad.nocknock.presenters.MainView +import com.afollestad.nocknock.ui.addsite.intentToAdd +import com.afollestad.nocknock.ui.viewsite.intentToView import com.afollestad.nocknock.utilities.ext.ScopeReceiver import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver @@ -63,8 +63,10 @@ class MainActivity : AppCompatActivity(), MainView { @SuppressLint("CommitPrefEdits") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + injector().injectInto(this) setContentView(R.layout.activity_main) + presenter.takeView(this) toolbar.inflateMenu(R.menu.menu_main) toolbar.setOnMenuItemClickListener { item -> @@ -86,8 +88,6 @@ class MainActivity : AppCompatActivity(), MainView { ADD_SITE_RQ ) } - - presenter.takeView(this) } override fun onResume() { @@ -110,15 +110,21 @@ class MainActivity : AppCompatActivity(), MainView { } override fun setModels(models: List) { - adapter.set(models) - emptyText.showOrHide(models.isEmpty()) + list.post { + adapter.set(models) + emptyText.showOrHide(models.isEmpty()) + } } - override fun updateModel(model: ServerModel) = adapter.update(model) + override fun updateModel(model: ServerModel) { + list.post { adapter.update(model) } + } override fun onSiteDeleted(model: ServerModel) { - adapter.remove(model) - emptyText.showOrHide(adapter.itemCount == 0) + list.post { + adapter.remove(model) + emptyText.showOrHide(adapter.itemCount == 0) + } } override fun scopeWhileAttached( diff --git a/app/src/main/java/com/afollestad/nocknock/presenters/MainPresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt similarity index 93% rename from app/src/main/java/com/afollestad/nocknock/presenters/MainPresenter.kt rename to app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt index a8c0009..ff3b028 100644 --- a/app/src/main/java/com/afollestad/nocknock/presenters/MainPresenter.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt @@ -3,7 +3,7 @@ * * Designed and developed by Aidan Follestad (@afollestad) */ -package com.afollestad.nocknock.presenters +package com.afollestad.nocknock.ui.main import android.content.Intent import com.afollestad.nocknock.data.ServerModel @@ -58,14 +58,14 @@ class RealMainPresenter @Inject constructor( override fun resume() { notificationManager.cancelStatusNotifications() - view?.run { + view!!.run { setModels(listOf()) scopeWhileAttached(Main) { launch(coroutineContext) { val models = async(IO) { serverModelStore.get() - } - setModels(models.await()) + }.await() + setModels(models) } } } @@ -82,7 +82,7 @@ class RealMainPresenter @Inject constructor( override fun removeSite(site: ServerModel) { checkStatusManager.cancelCheck(site) notificationManager.cancelStatusNotification(site) - view?.scopeWhileAttached(Main) { + view!!.scopeWhileAttached(Main) { launch(coroutineContext) { async(IO) { serverModelStore.delete(site) }.await() view?.onSiteDeleted(site) @@ -95,7 +95,7 @@ class RealMainPresenter @Inject constructor( } private fun ensureCheckJobs() { - view?.scopeWhileAttached(IO) { + 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/ui/main/MainView.kt similarity index 92% rename from app/src/main/java/com/afollestad/nocknock/presenters/MainView.kt rename to app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt index bc3ed8d..43324b8 100644 --- a/app/src/main/java/com/afollestad/nocknock/presenters/MainView.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt @@ -3,7 +3,7 @@ * * Designed and developed by Aidan Follestad (@afollestad) */ -package com.afollestad.nocknock.presenters +package com.afollestad.nocknock.ui.main import com.afollestad.nocknock.data.ServerModel import com.afollestad.nocknock.utilities.ext.ScopeReceiver diff --git a/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt similarity index 98% rename from app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.kt rename to app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt index eedd8f4..14f0937 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt @@ -3,7 +3,7 @@ * * Designed and developed by Aidan Follestad (@afollestad) */ -package com.afollestad.nocknock.ui +package com.afollestad.nocknock.ui.viewsite import android.annotation.SuppressLint import android.content.BroadcastReceiver @@ -39,6 +39,7 @@ import com.afollestad.nocknock.engine.db.ServerModelStore import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager import com.afollestad.nocknock.notifications.NockNotificationManager +import com.afollestad.nocknock.ui.main.MainActivity import com.afollestad.nocknock.utilities.ext.formatDate import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.isHttpOrHttps @@ -108,11 +109,15 @@ class ViewSiteActivity : AppCompatActivity(), context: Context, intent: Intent ) { - log("Received broadcast ${intent.action}") + log( + "Received broadcast ${intent.action}" + ) val model = intent.getSerializableExtra(KEY_VIEW_MODEL) as? ServerModel if (model != null) { this@ViewSiteActivity.currentModel = model - log("Received model update: $currentModel") + log( + "Received model update: $currentModel" + ) displayCurrentModel() } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 2064711..0ca585a 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -6,7 +6,7 @@ android:id="@+id/rootView" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".ui.MainActivity" + tools:context=".ui.main.MainActivity" > Please enter a name! Please enter a URL. Please enter a valid URL. + Please input a check interval. + Please input a search term. + Please input a validation script. Options Already checking sites! diff --git a/app/src/test/java/com/afollestad/nocknock/AddSitePresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/AddSitePresenterTest.kt new file mode 100644 index 0000000..692c5ca --- /dev/null +++ b/app/src/test/java/com/afollestad/nocknock/AddSitePresenterTest.kt @@ -0,0 +1,241 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock + +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.engine.db.ServerModelStore +import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.ui.addsite.AddSiteView +import com.afollestad.nocknock.ui.addsite.InputErrors +import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter +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.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test + +class AddSitePresenterTest { + + private val serverModelStore = mock { + on { runBlocking { put(any()) } } doAnswer { inv -> + inv.getArgument(0) + } + } + private val checkStatusManager = mock() + private val view = mock() + + private val presenter = RealAddSitePresenter( + serverModelStore, + 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 onUrlInputFocusChange_focused() { + presenter.onUrlInputFocusChange(true, "hello") + verifyNoMoreInteractions(view) + } + + @Test fun onUrlInputFocusChange_empty() { + presenter.onUrlInputFocusChange(false, "") + verifyNoMoreInteractions(view) + } + + @Test fun onUrlInputFocusChange_notHttpHttps() { + presenter.onUrlInputFocusChange(false, "ftp://hello.com") + verify(view).showOrHideUrlSchemeWarning(true) + } + + @Test fun onUrlInputFocusChange_isHttpOrHttps() { + presenter.onUrlInputFocusChange(false, "http://hello.com") + presenter.onUrlInputFocusChange(false, "https://hello.com") + verify(view, times(2)).showOrHideUrlSchemeWarning(false) + } + + @Test fun onValidationModeSelected_statusCode() { + presenter.onValidationModeSelected(0) + verify(view).showOrHideValidationSearchTerm(false) + verify(view).showOrHideScriptInput(false) + verify(view).setValidationModeDescription(R.string.validation_mode_status_desc) + } + + @Test fun onValidationModeSelected_termSearch() { + presenter.onValidationModeSelected(1) + verify(view).showOrHideValidationSearchTerm(true) + verify(view).showOrHideScriptInput(false) + verify(view).setValidationModeDescription(R.string.validation_mode_term_desc) + } + + @Test fun onValidationModeSelected_javaScript() { + presenter.onValidationModeSelected(2) + verify(view).showOrHideValidationSearchTerm(false) + verify(view).showOrHideScriptInput(true) + verify(view).setValidationModeDescription(R.string.validation_mode_javascript_desc) + } + + @Test(expected = IllegalStateException::class) + fun onValidationModeSelected_other() { + presenter.onValidationModeSelected(3) + } + + @Test fun commit_nameError() { + presenter.commit( + "", + "https://test.com", + 1, + STATUS_CODE, + null + ) + + val inputErrorsCaptor = argumentCaptor() + verify(view).setInputErrors(inputErrorsCaptor.capture()) + verify(checkStatusManager, never()) + .scheduleCheck(any(), any(), any(), any()) + + val errors = inputErrorsCaptor.firstValue + assertThat(errors.name).isEqualTo(R.string.please_enter_name) + } + + @Test fun commit_urlEmptyError() { + presenter.commit( + "Testing", + "", + 1, + STATUS_CODE, + null + ) + + val inputErrorsCaptor = argumentCaptor() + verify(view).setInputErrors(inputErrorsCaptor.capture()) + verify(checkStatusManager, never()) + .scheduleCheck(any(), any(), any(), any()) + + val errors = inputErrorsCaptor.firstValue + assertThat(errors.url).isEqualTo(R.string.please_enter_url) + } + + @Test fun commit_urlFormatError() { + presenter.commit( + "Testing", + "ftp://hello.com", + 1, + STATUS_CODE, + null + ) + + val inputErrorsCaptor = argumentCaptor() + verify(view).setInputErrors(inputErrorsCaptor.capture()) + verify(checkStatusManager, never()) + .scheduleCheck(any(), any(), any(), any()) + + val errors = inputErrorsCaptor.firstValue + assertThat(errors.url).isEqualTo(R.string.please_enter_valid_url) + } + + @Test fun commit_checkIntervalError() { + presenter.commit( + "Testing", + "https://hello.com", + -1, + STATUS_CODE, + null + ) + + val inputErrorsCaptor = argumentCaptor() + verify(view).setInputErrors(inputErrorsCaptor.capture()) + verify(checkStatusManager, never()) + .scheduleCheck(any(), any(), any(), any()) + + val errors = inputErrorsCaptor.firstValue + assertThat(errors.checkInterval).isEqualTo(R.string.please_enter_check_interval) + } + + @Test fun commit_termSearchError() { + presenter.commit( + "Testing", + "https://hello.com", + 1, + TERM_SEARCH, + null + ) + + val inputErrorsCaptor = argumentCaptor() + verify(view).setInputErrors(inputErrorsCaptor.capture()) + verify(checkStatusManager, never()) + .scheduleCheck(any(), any(), any(), any()) + + val errors = inputErrorsCaptor.firstValue + assertThat(errors.termSearch).isEqualTo(R.string.please_enter_search_term) + } + + @Test fun commit_javaScript_error() { + presenter.commit( + "Testing", + "https://hello.com", + 1, + JAVASCRIPT, + null + ) + + val inputErrorsCaptor = argumentCaptor() + verify(view).setInputErrors(inputErrorsCaptor.capture()) + verify(checkStatusManager, never()) + .scheduleCheck(any(), any(), any(), any()) + + val errors = inputErrorsCaptor.firstValue + assertThat(errors.javaScript).isEqualTo(R.string.please_enter_javaScript) + } + + @Test fun commit_success() = runBlocking { + presenter.commit( + "Testing", + "https://hello.com", + 1, + STATUS_CODE, + null + ) + + val modelCaptor = argumentCaptor() + verify(view).setLoading() + verify(serverModelStore).put(modelCaptor.capture()) + val model = modelCaptor.firstValue + verify(view, never()).setInputErrors(any()) + verify(checkStatusManager).scheduleCheck( + site = model, + rightNow = true, + cancelPrevious = true, + fromFinishingJob = false + ) + verify(view).setDoneLoading() + verify(view).onSiteAdded() + } +} diff --git a/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt index afe122f..f64df8d 100644 --- a/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt @@ -13,8 +13,8 @@ import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTIO 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.ui.main.MainView +import com.afollestad.nocknock.ui.main.RealMainPresenter import com.afollestad.nocknock.utilities.ext.ScopeReceiver import com.google.common.truth.Truth.assertThat import com.nhaarman.mockitokotlin2.any diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt index a5b9ae4..79d9f49 100644 --- a/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt +++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt @@ -78,7 +78,7 @@ class RealNockNotificationManager @Inject constructor( log("Posting status notification for site ${model.id}...") val viewSiteActivityCls = - Class.forName("com.afollestad.nocknock.ui.ViewSiteActivity") + Class.forName("com.afollestad.nocknock.ui.viewsite.ViewSiteActivity") val openIntent = Intent(app, viewSiteActivityCls).apply { putExtra(KEY_MODEL, model) addFlags(FLAG_ACTIVITY_NEW_TASK) diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/CheckIntervalLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/CheckIntervalLayout.kt index 1b35548..4865e58 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/CheckIntervalLayout.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/CheckIntervalLayout.kt @@ -49,6 +49,10 @@ class CheckIntervalLayout( spinner.adapter = spinnerAdapter } + fun setError(error: String?) { + input.error = error + } + fun set(interval: Long) { when { interval >= WEEK -> { diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/JavaScriptInputLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/JavaScriptInputLayout.kt index 6b97bd8..5c76252 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/JavaScriptInputLayout.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/JavaScriptInputLayout.kt @@ -11,7 +11,9 @@ import android.widget.HorizontalScrollView import androidx.annotation.CheckResult import com.afollestad.nocknock.viewcomponents.ext.dimenFloat import com.afollestad.nocknock.viewcomponents.ext.dimenInt +import com.afollestad.nocknock.viewcomponents.ext.showOrHide import com.afollestad.nocknock.viewcomponents.ext.trimmedText +import kotlinx.android.synthetic.main.javascript_input_layout.view.error_text import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput /** @author Aidan Follestad (afollestad) */ @@ -33,6 +35,11 @@ class JavaScriptInputLayout( inflate(context, R.layout.javascript_input_layout, this) } + fun setError(error: String?) { + error_text.showOrHide(error != null) + error_text.text = error + } + fun setCode(code: String?) { if (code.isNullOrEmpty()) { setDefaultCode() @@ -41,9 +48,9 @@ class JavaScriptInputLayout( userInput.setText(code.trim()) } - fun setDefaultCode() = userInput.setText(R.string.default_js) - @CheckResult fun getCode() = userInput.trimmedText() fun clear() = userInput.setText("") + + private fun setDefaultCode() = userInput.setText(R.string.default_js) } diff --git a/viewcomponents/src/main/res/layout/javascript_input_layout.xml b/viewcomponents/src/main/res/layout/javascript_input_layout.xml index a5d90b7..49f282c 100644 --- a/viewcomponents/src/main/res/layout/javascript_input_layout.xml +++ b/viewcomponents/src/main/res/layout/javascript_input_layout.xml @@ -50,6 +50,17 @@ android:textSize="@dimen/code_font_size" /> + + diff --git a/viewcomponents/src/main/res/values/styles.xml b/viewcomponents/src/main/res/values/styles.xml index af9f3ad..c5790d3 100644 --- a/viewcomponents/src/main/res/values/styles.xml +++ b/viewcomponents/src/main/res/values/styles.xml @@ -39,4 +39,10 @@ ?android:textColorSecondary + +