Move majority of add site logic to a presenter, add unit tests

This commit is contained in:
Aidan Follestad 2018-12-01 01:30:25 -08:00
parent f87e1438d2
commit 225434b41a
21 changed files with 622 additions and 155 deletions

View file

@ -18,7 +18,7 @@
tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute">
<activity
android:name="com.afollestad.nocknock.ui.MainActivity"
android:name="com.afollestad.nocknock.ui.main.MainActivity"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@ -28,14 +28,14 @@
</activity>
<activity
android:name="com.afollestad.nocknock.ui.AddSiteActivity"
android:name="com.afollestad.nocknock.ui.addsite.AddSiteActivity"
android:label="@string/add_site"
android:launchMode="singleTop"
android:theme="@style/AppTheme.Transparent"
android:windowSoftInputMode="stateHidden"/>
<activity
android:name="com.afollestad.nocknock.ui.ViewSiteActivity"
android:name="com.afollestad.nocknock.ui.viewsite.ViewSiteActivity"
android:label="@string/view_site"
android:launchMode="singleTop"
android:theme="@style/AppTheme.Ink"

View file

@ -12,9 +12,9 @@ import com.afollestad.nocknock.di.DaggerAppComponent
import com.afollestad.nocknock.engine.statuscheck.BootReceiver
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
import com.afollestad.nocknock.notifications.NockNotificationManager
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.Injector
import com.afollestad.nocknock.utilities.ext.systemService
import okhttp3.OkHttpClient

View file

@ -110,10 +110,9 @@ class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<Serve
fun set(newModels: List<ServerModel>) {
this.models.clear()
if (newModels.isEmpty()) {
return
if (!newModels.isEmpty()) {
this.models.addAll(newModels)
}
this.models.addAll(newModels)
notifyDataSetChanged()
}

View file

@ -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

View file

@ -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
}

View file

@ -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<Int>()
private var revealCy by notNull<Int>()
@ -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) {

View file

@ -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
}
}

View file

@ -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
)
}

View file

@ -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<ServerModel>) {
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(

View file

@ -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()
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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"
>
<LinearLayout

View file

@ -23,6 +23,9 @@
<string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</string>
<string name="please_enter_valid_url">Please enter a valid URL.</string>
<string name="please_enter_check_interval">Please input a check interval.</string>
<string name="please_enter_search_term">Please input a search term.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
<string name="options">Options</string>
<string name="already_checking_sites">Already checking sites!</string>

View file

@ -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<ServerModelStore> {
on { runBlocking { put(any()) } } doAnswer { inv ->
inv.getArgument<ServerModel>(0)
}
}
private val checkStatusManager = mock<CheckStatusManager>()
private val view = mock<AddSiteView>()
private val presenter = RealAddSitePresenter(
serverModelStore,
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 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<InputErrors>()
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<InputErrors>()
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<InputErrors>()
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<InputErrors>()
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<InputErrors>()
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<InputErrors>()
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<ServerModel>()
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()
}
}

View file

@ -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

View file

@ -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)

View file

@ -49,6 +49,10 @@ class CheckIntervalLayout(
spinner.adapter = spinnerAdapter
}
fun setError(error: String?) {
input.error = error
}
fun set(interval: Long) {
when {
interval >= WEEK -> {

View file

@ -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)
}

View file

@ -50,6 +50,17 @@
android:textSize="@dimen/code_font_size"
/>
<TextView
android:id="@+id/error_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_less"
android:visibility="gone"
tools:text="Please enter some JavaScript!"
tools:visibility="visible"
style="@style/NockText.Error"
/>
</LinearLayout>
</merge>

View file

@ -39,4 +39,10 @@
<item name="android:textColorHint">?android:textColorSecondary</item>
</style>
<style name="NockText.Error">
<item name="android:textSize">@dimen/caption_font_size</item>
<item name="android:fontFamily">@font/lato_light</item>
<item name="android:textColor">@color/md_red</item>
</style>
</resources>