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 77b324b..31d14d2 100644 --- a/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt +++ b/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt @@ -9,6 +9,8 @@ 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 com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter +import com.afollestad.nocknock.ui.viewsite.ViewSitePresenter import dagger.Binds import dagger.Module import javax.inject.Singleton @@ -28,4 +30,10 @@ abstract class MainBindModule { abstract fun provideAddSitePresenter( presenter: RealAddSitePresenter ): AddSitePresenter + + @Binds + @Singleton + abstract fun provideViewSitePresenter( + presenter: RealViewSitePresenter + ): ViewSitePresenter } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt index 1f0b48b..ae57fa9 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt @@ -10,20 +10,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.net.Uri import android.os.Bundle -import android.util.Log -import android.util.Patterns.WEB_URL -import android.view.MenuItem -import android.view.View import android.widget.ArrayAdapter -import androidx.annotation.CheckResult import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.Toolbar import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.nocknock.BuildConfig import com.afollestad.nocknock.R import com.afollestad.nocknock.data.LAST_CHECK_NONE import com.afollestad.nocknock.data.ServerModel @@ -35,24 +27,17 @@ 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.data.textRes -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.ScopeReceiver import com.afollestad.nocknock.utilities.ext.formatDate import com.afollestad.nocknock.utilities.ext.injector -import com.afollestad.nocknock.utilities.ext.isHttpOrHttps 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.dimenFloat -import com.afollestad.nocknock.viewcomponents.ext.disable -import com.afollestad.nocknock.viewcomponents.ext.enable -import com.afollestad.nocknock.viewcomponents.ext.hide import com.afollestad.nocknock.viewcomponents.ext.onItemSelected import com.afollestad.nocknock.viewcomponents.ext.onScroll -import com.afollestad.nocknock.viewcomponents.ext.show import com.afollestad.nocknock.viewcomponents.ext.showOrHide import com.afollestad.nocknock.viewcomponents.ext.trimmedText import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout @@ -72,13 +57,8 @@ import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning import kotlinx.android.synthetic.main.activity_viewsite.toolbar import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.async -import kotlinx.coroutines.launch import javax.inject.Inject - -private const val KEY_VIEW_MODEL = "site_model" +import kotlin.coroutines.CoroutineContext /** @author Aidan Follestad (@afollestad) */ fun MainActivity.intentToView(model: ServerModel) = @@ -87,40 +67,15 @@ fun MainActivity.intentToView(model: ServerModel) = } /** @author Aidan Follestad (@afollestad) */ -class ViewSiteActivity : AppCompatActivity(), - View.OnClickListener, - Toolbar.OnMenuItemClickListener { - companion object { - private fun log(message: String) { - if (BuildConfig.DEBUG) { - Log.d("ViewSiteActivity", message) - } - } - } +class ViewSiteActivity : AppCompatActivity(), ViewSiteView { - private lateinit var currentModel: ServerModel - - @Inject lateinit var serverModelStore: ServerModelStore - @Inject lateinit var notificationManager: NockNotificationManager - @Inject lateinit var checkStatusManager: CheckStatusManager + @Inject lateinit var presenter: ViewSitePresenter private val intentReceiver = object : BroadcastReceiver() { override fun onReceive( context: Context, intent: Intent - ) { - 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" - ) - displayCurrentModel() - } - } + ) = presenter.onBroadcast(intent) } @SuppressLint("SetTextI18n") @@ -133,7 +88,13 @@ class ViewSiteActivity : AppCompatActivity(), toolbar.run { setNavigationOnClickListener { finish() } inflateMenu(R.menu.menu_viewsite) - setOnMenuItemClickListener(this@ViewSiteActivity) + setOnMenuItemClickListener { + when (it.itemId) { + R.id.refresh -> presenter.checkNow() + R.id.remove -> maybeRemoveSite() + } + return@setOnMenuItemClickListener true + } } scrollView.onScroll { @@ -145,25 +106,7 @@ class ViewSiteActivity : AppCompatActivity(), } 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 (!uri.isHttpOrHttps()) { - textUrlWarning.show() - textUrlWarning.setText(R.string.warning_http_url) - } else { - textUrlWarning.hide() - } - } + presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText()) } val validationOptionsAdapter = ArrayAdapter( @@ -174,34 +117,56 @@ class ViewSiteActivity : AppCompatActivity(), 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("Unexpected position: $pos") - } + doneBtn.setOnClickListener { + val checkInterval = checkIntervalLayout.getSelectedCheckInterval() + val validationMode = + responseValidationMode.selectedItemPosition.indexToValidationMode() + + presenter.commit( + name = inputName.trimmedText(), + url = inputUrl.trimmedText(), + checkInterval = checkInterval, + validationMode = validationMode, + validationContent = validationMode.validationContent() ) } - currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel - displayCurrentModel() + disableChecksButton.setOnClickListener { maybeDisableChecks() } + + presenter.takeView(this, intent) } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) { - currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel - displayCurrentModel() + presenter.onNewIntent(intent) + } + + 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) } } - @SuppressLint("SetTextI18n") - private fun displayCurrentModel() = with(currentModel) { + 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 displayModel(model: ServerModel) = with(model) { iconStatus.setStatus(this.status) inputName.setText(this.name) inputUrl.setText(this.url) @@ -217,26 +182,25 @@ class ViewSiteActivity : AppCompatActivity(), } } - textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate() + if (this.disabled) { + textNextCheck.setText(R.string.auto_checks_disabled) + } else { + textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate() + } checkIntervalLayout.set(this.checkInterval) responseValidationMode.setSelection(validationMode.value - 1) when (this.validationMode) { TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "") - JAVASCRIPT -> { - scriptInputLayout.setCode(this.validationContent) - } + JAVASCRIPT -> scriptInputLayout.setCode(this.validationContent) else -> { responseValidationSearchTerm.setText("") scriptInputLayout.clear() } } - disableChecksButton.setOnClickListener(this@ViewSiteActivity) disableChecksButton.showOrHide(!this.disabled) - - doneBtn.setOnClickListener(this@ViewSiteActivity) doneBtn.setText( if (this.disabled) R.string.renable_and_save_changes else R.string.save_changes @@ -245,6 +209,43 @@ class ViewSiteActivity : AppCompatActivity(), invalidateMenuForStatus() } + override fun setInputErrors(errors: InputErrors) { + 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 scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) = rootView.scopeWhileAttached(context, exec) + override fun onResume() { super.onResume() val filter = IntentFilter().apply { @@ -258,175 +259,41 @@ class ViewSiteActivity : AppCompatActivity(), safeUnregisterReceiver(intentReceiver) } - override fun onClick(view: View) = when (view.id) { - R.id.doneBtn -> performSaveChangesAndFinish() - R.id.disableChecksButton -> maybeDisableChecks() - else -> Unit - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - when (item.itemId) { - R.id.refresh -> performCheckNow() - R.id.remove -> maybeRemoveSite() - } - return true - } - - private fun performCheckNow() { - rootView.scopeWhileAttached(Main) { - launch(coroutineContext) { - disableChecksButton.disable() - loadingProgress.setLoading() - updateModelFromInput(false) - currentModel = currentModel.copy(status = WAITING) - displayCurrentModel() - - async(IO) { serverModelStore.update(currentModel) }.await() - - checkStatusManager.scheduleCheck( - site = currentModel, - rightNow = true, - cancelPrevious = true - ) - loadingProgress.setDone() - disableChecksButton.enable() - } - } - } - private fun maybeRemoveSite() { + val model = presenter.currentModel() MaterialDialog(this).show { title(R.string.remove_site) message( text = HtmlCompat.fromHtml( - context.getString(R.string.remove_site_prompt, currentModel.name), + context.getString(R.string.remove_site_prompt, model.name), FROM_HTML_MODE_LEGACY ) ) - positiveButton(R.string.remove) { - checkStatusManager.cancelCheck(currentModel) - notificationManager.cancelStatusNotification(currentModel) - performRemoveSite() - } + positiveButton(R.string.remove) { presenter.removeSite() } negativeButton(android.R.string.cancel) } } - private fun performRemoveSite() { - rootView.scopeWhileAttached(Main) { - launch(coroutineContext) { - loadingProgress.setLoading() - async(IO) { serverModelStore.delete(currentModel) }.await() - loadingProgress.setDone() - finish() - } - } - } - private fun maybeDisableChecks() { + val model = presenter.currentModel() MaterialDialog(this).show { title(R.string.disable_automatic_checks) message( text = HtmlCompat.fromHtml( - context.getString(R.string.disable_automatic_checks_prompt, currentModel.name), + context.getString(R.string.disable_automatic_checks_prompt, model.name), FROM_HTML_MODE_LEGACY ) ) - positiveButton(R.string.disable) { - checkStatusManager.cancelCheck(currentModel) - notificationManager.cancelStatusNotification(currentModel) - performDisableChecks() - } + positiveButton(R.string.disable) { presenter.disableChecks() } negativeButton(android.R.string.cancel) } } - private fun performDisableChecks() { - rootView.scopeWhileAttached(Main) { - launch(coroutineContext) { - loadingProgress.setLoading() - currentModel = currentModel.copy( - disabled = true, - lastCheck = LAST_CHECK_NONE - ) - async(IO) { serverModelStore.update(currentModel) }.await() - loadingProgress.setDone() - displayCurrentModel() // invalidate UI to reflect disabled state - } - } - } - - private fun performSaveChangesAndFinish() { - rootView.scopeWhileAttached(Main) { - launch(coroutineContext) { - loadingProgress.setLoading() - if (!updateModelFromInput(true)) { - // Validation didn't pass - loadingProgress.setDone() - return@launch - } - - async(IO) { serverModelStore.update(currentModel) }.await() - checkStatusManager.scheduleCheck( - site = currentModel, - rightNow = true, - cancelPrevious = true - ) - - loadingProgress.setDone() - setResult(RESULT_OK) - finish() - } - } - } - private fun invalidateMenuForStatus() { + val model = presenter.currentModel() val item = toolbar.menu.findItem(R.id.refresh) - item.isEnabled = currentModel.status != CHECKING && currentModel.status != WAITING - } - - @CheckResult private fun updateModelFromInput(withValidation: Boolean): Boolean { - currentModel = currentModel.copy( - name = inputName.trimmedText(), - url = inputUrl.trimmedText(), - status = WAITING, - disabled = false - ) - - if (withValidation && currentModel.name.isEmpty()) { - inputName.error = getString(R.string.please_enter_name) - return false - } else { - inputName.error = null - } - - if (withValidation && currentModel.url.isEmpty()) { - inputUrl.error = getString(R.string.please_enter_url) - return false - } else { - inputUrl.error = null - if (withValidation && !WEB_URL.matcher(currentModel.url).find()) { - inputUrl.error = getString(R.string.please_enter_valid_url) - return false - } else { - val uri = Uri.parse(currentModel.url) - if (uri.scheme == null) { - currentModel = currentModel.copy(url = "http://${currentModel.url}") - } - } - } - - val selectedCheckInterval = checkIntervalLayout.getSelectedCheckInterval() - val selectedValidationMode = - responseValidationMode.selectedItemPosition.indexToValidationMode() - - currentModel = currentModel.copy( - checkInterval = selectedCheckInterval, - validationMode = selectedValidationMode, - validationContent = selectedValidationMode.validationContent() - ) - - return true + item.isEnabled = model.status != CHECKING && + model.status != WAITING } private fun ValidationMode.validationContent() = when (this) { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt new file mode 100644 index 0000000..42d8c26 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt @@ -0,0 +1,268 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.viewsite + +import android.content.Intent +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.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 okhttp3.HttpUrl +import org.jetbrains.annotations.TestOnly +import javax.inject.Inject + +const val KEY_VIEW_MODEL = "site_model" + +/** @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 ViewSitePresenter { + + fun takeView( + view: ViewSiteView, + intent: Intent + ) + + fun onBroadcast(intent: Intent) + + fun onNewIntent(intent: Intent?) + + fun onUrlInputFocusChange( + focused: Boolean, + content: String + ) + + fun onValidationModeSelected(index: Int) + + fun commit( + name: String, + url: String, + checkInterval: Long, + validationMode: ValidationMode, + validationContent: String? + ) + + fun checkNow() + + fun disableChecks() + + fun removeSite() + + fun currentModel(): ServerModel + + fun dropView() +} + +/** @author Aidan Follestad (@afollestad) */ +class RealViewSitePresenter @Inject constructor( + private val serverModelStore: ServerModelStore, + private val checkStatusManager: CheckStatusManager, + private val notificationManager: NockNotificationManager +) : ViewSitePresenter { + + private var view: ViewSiteView? = null + private var currentModel: ServerModel? = null + + override fun takeView( + view: ViewSiteView, + intent: Intent + ) { + this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel + this.view = view.apply { + displayModel(currentModel!!) + } + } + + override fun onBroadcast(intent: Intent) { + if (intent.action == ACTION_STATUS_UPDATE) { + val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return + this.currentModel = model + view?.run { + displayModel(model) + setDoneLoading() // in case this is the result of a manual refresh + } + } + } + + override fun onNewIntent(intent: Intent?) { + if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) { + currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel + view?.displayModel(currentModel!!) + } + } + + 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 = currentModel!!.copy( + name = name, + url = url, + status = WAITING, + checkInterval = checkInterval, + validationMode = validationMode, + validationContent = validationContent, + disabled = false + ) + + with(view!!) { + scopeWhileAttached(Main) { + launch(coroutineContext) { + setLoading() + async(IO) { serverModelStore.update(newModel) }.await() + checkStatusManager.scheduleCheck( + site = newModel, + rightNow = true, + cancelPrevious = true + ) + setDoneLoading() + view?.finish() + } + } + } + } + + override fun checkNow() = with(view!!) { + setLoading() + val checkModel = currentModel!!.copy( + status = WAITING + ) + view?.displayModel(checkModel) + checkStatusManager.scheduleCheck( + site = checkModel, + rightNow = true, + cancelPrevious = true + ) + } + + override fun disableChecks() { + val site = currentModel!! + checkStatusManager.cancelCheck(site) + notificationManager.cancelStatusNotification(site) + + with(view!!) { + scopeWhileAttached(Main) { + launch(coroutineContext) { + setLoading() + currentModel = currentModel!!.copy(disabled = true) + async(IO) { serverModelStore.update(currentModel!!) }.await() + setDoneLoading() + view?.displayModel(currentModel!!) + } + } + } + } + + override fun removeSite() { + val site = currentModel!! + checkStatusManager.cancelCheck(site) + notificationManager.cancelStatusNotification(site) + + with(view!!) { + scopeWhileAttached(Main) { + launch(coroutineContext) { + setLoading() + async(IO) { serverModelStore.delete(site) }.await() + setDoneLoading() + view?.finish() + } + } + } + } + + override fun currentModel() = this.currentModel!! + + override fun dropView() { + view = null + currentModel = null + } + + @TestOnly fun setModel(model: ServerModel) { + this.currentModel = model + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt new file mode 100644 index 0000000..0cbd515 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt @@ -0,0 +1,38 @@ +/* + * Licensed under Apache-2.0 + * + * Designed and developed by Aidan Follestad (@afollestad) + */ +package com.afollestad.nocknock.ui.viewsite + +import androidx.annotation.StringRes +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.utilities.ext.ScopeReceiver +import kotlin.coroutines.CoroutineContext + +/** @author Aidan Follestad (@afollestad) */ +interface ViewSiteView { + + fun setLoading() + + fun setDoneLoading() + + fun displayModel(model: ServerModel) + + fun showOrHideUrlSchemeWarning(show: Boolean) + + fun showOrHideValidationSearchTerm(show: Boolean) + + fun showOrHideScriptInput(show: Boolean) + + fun setValidationModeDescription(@StringRes res: Int) + + fun setInputErrors(errors: InputErrors) + + fun scopeWhileAttached( + context: CoroutineContext, + exec: ScopeReceiver + ) + + fun finish() +} diff --git a/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt new file mode 100644 index 0000000..5678bb1 --- /dev/null +++ b/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt @@ -0,0 +1,362 @@ +/* + * 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.LAST_CHECK_NONE +import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.ServerStatus.WAITING +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.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.ui.viewsite.InputErrors +import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL +import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter +import com.afollestad.nocknock.ui.viewsite.ViewSiteView +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.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 ViewSitePresenterTest { + + private val serverModelStore = mock { + on { runBlocking { put(any()) } } doAnswer { inv -> + inv.getArgument(0) + } + } + private val checkStatusManager = mock() + private val notificationManager = mock() + private val view = mock() + + private val presenter = RealViewSitePresenter( + serverModelStore, + checkStatusManager, + notificationManager + ) + + @Before fun setup() { + doAnswer { + val exec = it.getArgument(1) + runBlocking { exec() } + Unit + }.whenever(view) + .scopeWhileAttached(any(), any()) + + val model = fakeModel().copy(lastCheck = 0) + val intent = fakeIntent("") + whenever(intent.getSerializableExtra(KEY_VIEW_MODEL)) + .doReturn(model) + presenter.takeView(view, intent) + assertThat(presenter.currentModel()).isEqualTo(model) + verify(view, times(1)).displayModel(model) + } + + @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) + assertThat(presenter.currentModel()).isEqualTo(model) + verify(view, times(1)).displayModel(model) + verify(view).setDoneLoading() + } + + @Test fun onNewIntent() { + val badIntent = fakeIntent(ACTION_STATUS_UPDATE) + presenter.onBroadcast(badIntent) + + val model = fakeModel() + val goodIntent = fakeIntent(ACTION_STATUS_UPDATE) + whenever(goodIntent.getSerializableExtra(KEY_VIEW_MODEL)) + .doReturn(model) + presenter.onBroadcast(goodIntent) + + verify(view, times(1)).displayModel(model) + } + + @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 { + val name = "Testing" + val url = "https://hello.com" + val checkInterval = 60000L + val validationMode = TERM_SEARCH + val validationContent = "Hello World" + + val disabledModel = presenter.currentModel() + .copy(disabled = true) + presenter.setModel(disabledModel) + + presenter.commit( + name, + url, + checkInterval, + validationMode, + validationContent + ) + + val modelCaptor = argumentCaptor() + verify(view).setLoading() + verify(serverModelStore).update(modelCaptor.capture()) + + val model = modelCaptor.firstValue + assertThat(model.name).isEqualTo(name) + assertThat(model.url).isEqualTo(url) + assertThat(model.checkInterval).isEqualTo(checkInterval) + assertThat(model.validationMode).isEqualTo(validationMode) + assertThat(model.validationContent).isEqualTo(validationContent) + assertThat(model.disabled).isFalse() + + verify(view, never()).setInputErrors(any()) + verify(checkStatusManager).scheduleCheck( + site = model, + rightNow = true, + cancelPrevious = true, + fromFinishingJob = false + ) + verify(view).setDoneLoading() + verify(view).finish() + } + + @Test fun checkNow() { + val newModel = presenter.currentModel() + .copy( + status = WAITING + ) + presenter.checkNow() + + verify(view).setLoading() + verify(view).displayModel(newModel) + verify(checkStatusManager).scheduleCheck( + site = newModel, + rightNow = true, + cancelPrevious = true + ) + } + + @Test fun disableChecks() = runBlocking { + val model = presenter.currentModel() + presenter.disableChecks() + + verify(checkStatusManager).cancelCheck(model) + verify(notificationManager).cancelStatusNotification(model) + verify(view).setLoading() + + val modelCaptor = argumentCaptor() + verify(serverModelStore).update(modelCaptor.capture()) + val newModel = modelCaptor.firstValue + assertThat(newModel.disabled).isTrue() + assertThat(newModel.lastCheck).isEqualTo(LAST_CHECK_NONE) + + verify(view).setDoneLoading() + verify(view, times(1)).displayModel(newModel) + } + + @Test fun removeSite() = runBlocking { + val model = presenter.currentModel() + presenter.removeSite() + + verify(checkStatusManager).cancelCheck(model) + verify(notificationManager).cancelStatusNotification(model) + verify(view).setLoading() + verify(serverModelStore).delete(model) + verify(view).setDoneLoading() + verify(view).finish() + } + + private fun fakeModel() = ServerModel( + id = 1, + 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/res/values/strings.xml b/utilities/src/main/res/values/strings.xml index 6558f8a..fda664e 100644 --- a/utilities/src/main/res/values/strings.xml +++ b/utilities/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Checks Disabled + Automatic Checks Disabled diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt index 7c2d0db..591f4a3 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt @@ -18,7 +18,7 @@ class LoadingIndicatorFrame( attrs: AttributeSet? = null ) : FrameLayout(context, attrs) { companion object { - private const val SHOW_DELAY_MS = 200L + private const val SHOW_DELAY_MS = 100L } private val showRunnable = Runnable { show() }