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
commit 225434b41a
21 changed files with 622 additions and 155 deletions

View file

@ -18,7 +18,7 @@
tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute"> tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute">
<activity <activity
android:name="com.afollestad.nocknock.ui.MainActivity" android:name="com.afollestad.nocknock.ui.main.MainActivity"
android:launchMode="singleTop"> android:launchMode="singleTop">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
@ -28,14 +28,14 @@
</activity> </activity>
<activity <activity
android:name="com.afollestad.nocknock.ui.AddSiteActivity" android:name="com.afollestad.nocknock.ui.addsite.AddSiteActivity"
android:label="@string/add_site" android:label="@string/add_site"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/AppTheme.Transparent" android:theme="@style/AppTheme.Transparent"
android:windowSoftInputMode="stateHidden"/> android:windowSoftInputMode="stateHidden"/>
<activity <activity
android:name="com.afollestad.nocknock.ui.ViewSiteActivity" android:name="com.afollestad.nocknock.ui.viewsite.ViewSiteActivity"
android:label="@string/view_site" android:label="@string/view_site"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/AppTheme.Ink" 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.BootReceiver
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.AddSiteActivity import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.MainActivity import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.ui.ViewSiteActivity import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.Injector import com.afollestad.nocknock.utilities.Injector
import com.afollestad.nocknock.utilities.ext.systemService import com.afollestad.nocknock.utilities.ext.systemService
import okhttp3.OkHttpClient import okhttp3.OkHttpClient

View file

@ -110,10 +110,9 @@ class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<Serve
fun set(newModels: List<ServerModel>) { fun set(newModels: List<ServerModel>) {
this.models.clear() this.models.clear()
if (newModels.isEmpty()) { if (!newModels.isEmpty()) {
return
}
this.models.addAll(newModels) this.models.addAll(newModels)
}
notifyDataSetChanged() 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.BootReceiver
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
import com.afollestad.nocknock.notifications.NotificationsModule import com.afollestad.nocknock.notifications.NotificationsModule
import com.afollestad.nocknock.ui.AddSiteActivity import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.MainActivity import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.ui.ViewSiteActivity import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.UtilitiesModule import com.afollestad.nocknock.utilities.UtilitiesModule
import dagger.BindsInstance import dagger.BindsInstance
import dagger.Component import dagger.Component

View file

@ -5,8 +5,10 @@
*/ */
package com.afollestad.nocknock.di package com.afollestad.nocknock.di
import com.afollestad.nocknock.presenters.MainPresenter import com.afollestad.nocknock.ui.addsite.AddSitePresenter
import com.afollestad.nocknock.presenters.RealMainPresenter 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.Binds
import dagger.Module import dagger.Module
import javax.inject.Singleton import javax.inject.Singleton
@ -20,4 +22,10 @@ abstract class MainBindModule {
abstract fun provideMainPresenter( abstract fun provideMainPresenter(
presenter: RealMainPresenter presenter: RealMainPresenter
): MainPresenter ): MainPresenter
@Binds
@Singleton
abstract fun provideAddSitePresenter(
presenter: RealAddSitePresenter
): AddSitePresenter
} }

View file

@ -3,35 +3,29 @@
* *
* Designed and developed by Aidan Follestad (@afollestad) * Designed and developed by Aidan Follestad (@afollestad)
*/ */
package com.afollestad.nocknock.ui package com.afollestad.nocknock.ui.addsite
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION import android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Patterns.WEB_URL
import android.view.View
import android.view.ViewAnimationUtils.createCircularReveal import android.view.ViewAnimationUtils.createCircularReveal
import android.view.animation.AccelerateInterpolator import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.afollestad.nocknock.R 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
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.indexToValidationMode import com.afollestad.nocknock.data.indexToValidationMode
import com.afollestad.nocknock.engine.db.ServerModelStore import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.onEnd import com.afollestad.nocknock.utilities.ext.onEnd
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.conceal 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.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onLayout import com.afollestad.nocknock.viewcomponents.ext.onLayout
import com.afollestad.nocknock.viewcomponents.ext.show 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.inputName
import kotlinx.android.synthetic.main.activity_addsite.inputUrl import kotlinx.android.synthetic.main.activity_addsite.inputUrl
import kotlinx.android.synthetic.main.activity_addsite.loadingProgress 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.responseValidationMode
import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_addsite.rootView import kotlinx.android.synthetic.main.activity_addsite.rootView
import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.toolbar 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.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 javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlin.math.max import kotlin.math.max
import kotlin.properties.Delegates.notNull import kotlin.properties.Delegates.notNull
@ -76,7 +65,7 @@ fun MainActivity.intentToAdd(
} }
/** @author Aidan Follestad (afollestad) */ /** @author Aidan Follestad (afollestad) */
class AddSiteActivity : AppCompatActivity(), View.OnClickListener { class AddSiteActivity : AppCompatActivity(), AddSiteView {
companion object { companion object {
private const val REVEAL_DURATION = 300L private const val REVEAL_DURATION = 300L
@ -84,8 +73,7 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener {
private var isClosing: Boolean = false private var isClosing: Boolean = false
@Inject lateinit var serverModelStore: ServerModelStore @Inject lateinit var presenter: AddSitePresenter
@Inject lateinit var checkStatusManager: CheckStatusManager
private var revealCx by notNull<Int>() private var revealCx by notNull<Int>()
private var revealCy by notNull<Int>() private var revealCy by notNull<Int>()
@ -94,9 +82,11 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener {
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
injector().injectInto(this)
injector().injectInto(this)
setContentView(R.layout.activity_addsite) setContentView(R.layout.activity_addsite)
presenter.takeView(this)
toolbar.setNavigationOnClickListener { closeActivityWithReveal() } toolbar.setNavigationOnClickListener { closeActivityWithReveal() }
if (savedInstanceState == null) { if (savedInstanceState == null) {
@ -117,25 +107,7 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener {
} }
inputUrl.setOnFocusChangeListener { _, hasFocus -> inputUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) { presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
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()
}
}
} }
val validationOptionsAdapter = ArrayAdapter( val validationOptionsAdapter = ArrayAdapter(
@ -146,23 +118,91 @@ class AddSiteActivity : AppCompatActivity(), View.OnClickListener {
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter responseValidationMode.adapter = validationOptionsAdapter
responseValidationMode.onItemSelected { pos -> responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
responseValidationSearchTerm.showOrHide(pos == 1)
scriptInputLayout.showOrHide(pos == 2)
validationModeDescription.setText( doneBtn.setOnClickListener {
when (pos) { val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
0 -> R.string.validation_mode_status_desc val validationMode =
1 -> R.string.validation_mode_term_desc responseValidationMode.selectedItemPosition.indexToValidationMode()
2 -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unknown validation mode position: $pos") isClosing = true
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationMode.validationContent()
)
}
}
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
} }
) )
} }
doneBtn.setOnClickListener(this) 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() { private fun circularRevealActivity() {
val circularReveal = val circularReveal =
createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius) 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() override fun onBackPressed() = closeActivityWithReveal()
private fun ValidationMode.validationContent() = when (this) { 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) * Designed and developed by Aidan Follestad (@afollestad)
*/ */
package com.afollestad.nocknock.ui package com.afollestad.nocknock.ui.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.ActivityOptions.makeSceneTransitionAnimation 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.data.ServerModel
import com.afollestad.nocknock.dialogs.AboutDialog import com.afollestad.nocknock.dialogs.AboutDialog
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.presenters.MainPresenter import com.afollestad.nocknock.ui.addsite.intentToAdd
import com.afollestad.nocknock.presenters.MainView import com.afollestad.nocknock.ui.viewsite.intentToView
import com.afollestad.nocknock.utilities.ext.ScopeReceiver import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
@ -63,8 +63,10 @@ class MainActivity : AppCompatActivity(), MainView {
@SuppressLint("CommitPrefEdits") @SuppressLint("CommitPrefEdits")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
injector().injectInto(this) injector().injectInto(this)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
presenter.takeView(this)
toolbar.inflateMenu(R.menu.menu_main) toolbar.inflateMenu(R.menu.menu_main)
toolbar.setOnMenuItemClickListener { item -> toolbar.setOnMenuItemClickListener { item ->
@ -86,8 +88,6 @@ class MainActivity : AppCompatActivity(), MainView {
ADD_SITE_RQ ADD_SITE_RQ
) )
} }
presenter.takeView(this)
} }
override fun onResume() { override fun onResume() {
@ -110,16 +110,22 @@ class MainActivity : AppCompatActivity(), MainView {
} }
override fun setModels(models: List<ServerModel>) { override fun setModels(models: List<ServerModel>) {
list.post {
adapter.set(models) adapter.set(models)
emptyText.showOrHide(models.isEmpty()) 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) { override fun onSiteDeleted(model: ServerModel) {
list.post {
adapter.remove(model) adapter.remove(model)
emptyText.showOrHide(adapter.itemCount == 0) emptyText.showOrHide(adapter.itemCount == 0)
} }
}
override fun scopeWhileAttached( override fun scopeWhileAttached(
context: CoroutineContext, context: CoroutineContext,

View file

@ -3,7 +3,7 @@
* *
* Designed and developed by Aidan Follestad (@afollestad) * Designed and developed by Aidan Follestad (@afollestad)
*/ */
package com.afollestad.nocknock.presenters package com.afollestad.nocknock.ui.main
import android.content.Intent import android.content.Intent
import com.afollestad.nocknock.data.ServerModel import com.afollestad.nocknock.data.ServerModel
@ -58,14 +58,14 @@ class RealMainPresenter @Inject constructor(
override fun resume() { override fun resume() {
notificationManager.cancelStatusNotifications() notificationManager.cancelStatusNotifications()
view?.run { view!!.run {
setModels(listOf()) setModels(listOf())
scopeWhileAttached(Main) { scopeWhileAttached(Main) {
launch(coroutineContext) { launch(coroutineContext) {
val models = async(IO) { val models = async(IO) {
serverModelStore.get() serverModelStore.get()
} }.await()
setModels(models.await()) setModels(models)
} }
} }
} }
@ -82,7 +82,7 @@ class RealMainPresenter @Inject constructor(
override fun removeSite(site: ServerModel) { override fun removeSite(site: ServerModel) {
checkStatusManager.cancelCheck(site) checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site) notificationManager.cancelStatusNotification(site)
view?.scopeWhileAttached(Main) { view!!.scopeWhileAttached(Main) {
launch(coroutineContext) { launch(coroutineContext) {
async(IO) { serverModelStore.delete(site) }.await() async(IO) { serverModelStore.delete(site) }.await()
view?.onSiteDeleted(site) view?.onSiteDeleted(site)
@ -95,7 +95,7 @@ class RealMainPresenter @Inject constructor(
} }
private fun ensureCheckJobs() { private fun ensureCheckJobs() {
view?.scopeWhileAttached(IO) { view!!.scopeWhileAttached(IO) {
launch(coroutineContext) { launch(coroutineContext) {
checkStatusManager.ensureScheduledChecks() checkStatusManager.ensureScheduledChecks()
} }

View file

@ -3,7 +3,7 @@
* *
* Designed and developed by Aidan Follestad (@afollestad) * 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.data.ServerModel
import com.afollestad.nocknock.utilities.ext.ScopeReceiver import com.afollestad.nocknock.utilities.ext.ScopeReceiver

View file

@ -3,7 +3,7 @@
* *
* Designed and developed by Aidan Follestad (@afollestad) * Designed and developed by Aidan Follestad (@afollestad)
*/ */
package com.afollestad.nocknock.ui package com.afollestad.nocknock.ui.viewsite
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver 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.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager 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.formatDate
import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.isHttpOrHttps import com.afollestad.nocknock.utilities.ext.isHttpOrHttps
@ -108,11 +109,15 @@ class ViewSiteActivity : AppCompatActivity(),
context: Context, context: Context,
intent: Intent intent: Intent
) { ) {
log("Received broadcast ${intent.action}") log(
"Received broadcast ${intent.action}"
)
val model = intent.getSerializableExtra(KEY_VIEW_MODEL) as? ServerModel val model = intent.getSerializableExtra(KEY_VIEW_MODEL) as? ServerModel
if (model != null) { if (model != null) {
this@ViewSiteActivity.currentModel = model this@ViewSiteActivity.currentModel = model
log("Received model update: $currentModel") log(
"Received model update: $currentModel"
)
displayCurrentModel() displayCurrentModel()
} }
} }

View file

@ -6,7 +6,7 @@
android:id="@+id/rootView" android:id="@+id/rootView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.MainActivity" tools:context=".ui.main.MainActivity"
> >
<LinearLayout <LinearLayout

View file

@ -23,6 +23,9 @@
<string name="please_enter_name">Please enter a name!</string> <string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</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_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="options">Options</string>
<string name="already_checking_sites">Already checking sites!</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.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.presenters.MainView import com.afollestad.nocknock.ui.main.MainView
import com.afollestad.nocknock.presenters.RealMainPresenter import com.afollestad.nocknock.ui.main.RealMainPresenter
import com.afollestad.nocknock.utilities.ext.ScopeReceiver import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.any

View file

@ -78,7 +78,7 @@ class RealNockNotificationManager @Inject constructor(
log("Posting status notification for site ${model.id}...") log("Posting status notification for site ${model.id}...")
val viewSiteActivityCls = val viewSiteActivityCls =
Class.forName("com.afollestad.nocknock.ui.ViewSiteActivity") Class.forName("com.afollestad.nocknock.ui.viewsite.ViewSiteActivity")
val openIntent = Intent(app, viewSiteActivityCls).apply { val openIntent = Intent(app, viewSiteActivityCls).apply {
putExtra(KEY_MODEL, model) putExtra(KEY_MODEL, model)
addFlags(FLAG_ACTIVITY_NEW_TASK) addFlags(FLAG_ACTIVITY_NEW_TASK)

View file

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

View file

@ -11,7 +11,9 @@ import android.widget.HorizontalScrollView
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.dimenInt import com.afollestad.nocknock.viewcomponents.ext.dimenInt
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.trimmedText 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 import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput
/** @author Aidan Follestad (afollestad) */ /** @author Aidan Follestad (afollestad) */
@ -33,6 +35,11 @@ class JavaScriptInputLayout(
inflate(context, R.layout.javascript_input_layout, this) 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?) { fun setCode(code: String?) {
if (code.isNullOrEmpty()) { if (code.isNullOrEmpty()) {
setDefaultCode() setDefaultCode()
@ -41,9 +48,9 @@ class JavaScriptInputLayout(
userInput.setText(code.trim()) userInput.setText(code.trim())
} }
fun setDefaultCode() = userInput.setText(R.string.default_js)
@CheckResult fun getCode() = userInput.trimmedText() @CheckResult fun getCode() = userInput.trimmedText()
fun clear() = userInput.setText("") 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" 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> </LinearLayout>
</merge> </merge>

View file

@ -39,4 +39,10 @@
<item name="android:textColorHint">?android:textColorSecondary</item> <item name="android:textColorHint">?android:textColorSecondary</item>
</style> </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> </resources>