Initial implementation of a presenter-less app, just using view models.

This commit is contained in:
Aidan Follestad 2018-12-06 13:05:43 -08:00
parent 88ae30c0c9
commit c9750f5f66
58 changed files with 2271 additions and 2800 deletions

View file

@ -28,6 +28,8 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:' + versions.androidx
implementation 'com.google.android.material:material:' + versions.androidx
kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
implementation 'com.google.dagger:dagger:' + versions.dagger

View file

@ -42,11 +42,11 @@
android:windowSoftInputMode="stateHidden"/>
<service
android:name=".engine.statuscheck.ValidationJob"
android:name=".engine.validation.ValidationJob"
android:label="@string/check_service_name"
android:permission="android.permission.BIND_JOB_SERVICE"/>
<receiver android:name=".engine.statuscheck.BootReceiver">
<receiver android:name=".engine.validation.BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>

View file

@ -18,8 +18,8 @@ package com.afollestad.nocknock
import android.app.Application
import com.afollestad.nocknock.di.AppComponent
import com.afollestad.nocknock.di.DaggerAppComponent
import com.afollestad.nocknock.engine.statuscheck.BootReceiver
import com.afollestad.nocknock.engine.statuscheck.ValidationJob
import com.afollestad.nocknock.engine.validation.BootReceiver
import com.afollestad.nocknock.engine.validation.ValidationJob
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.main.MainActivity

View file

@ -18,6 +18,7 @@ package com.afollestad.nocknock.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil.calculateDiff
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
@ -95,55 +96,18 @@ class SiteViewHolder constructor(
/** @author Aidan Follestad (@afollestad) */
class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<SiteViewHolder>() {
private val models = mutableListOf<Site>()
private var models = mutableListOf<Site>()
internal fun performClick(
index: Int,
longClick: Boolean
) = listener.invoke(models[index], longClick)
fun add(model: Site) {
models.add(model)
notifyItemInserted(models.size - 1)
}
fun update(target: Site) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
update(i, target)
break
}
}
}
private fun update(
index: Int,
model: Site
) {
models[index] = model
notifyItemChanged(index)
}
fun remove(index: Int) {
models.removeAt(index)
notifyItemRemoved(index)
}
fun remove(target: Site) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
remove(i)
break
}
}
}
fun set(newModels: List<Site>) {
this.models.clear()
if (!newModels.isEmpty()) {
this.models.addAll(newModels)
}
notifyDataSetChanged()
val formerModels = this.models
this.models = newModels.toMutableList()
val diffResult = calculateDiff(SiteDiffCallback(formerModels, this.models))
diffResult.dispatchUpdatesTo(this)
}
override fun onCreateViewHolder(

View file

@ -13,36 +13,28 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.viewsite
package com.afollestad.nocknock.adapter
import androidx.annotation.StringRes
import androidx.recyclerview.widget.DiffUtil
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
interface ViewSiteView {
class SiteDiffCallback(
private val oldItems: List<Site>,
private val newItems: List<Site>
) : DiffUtil.Callback() {
fun setLoading()
override fun getOldListSize() = oldItems.size
fun setDoneLoading()
override fun getNewListSize() = newItems.size
fun displayModel(model: Site)
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int
) = oldItems[oldItemPosition].id == oldItems[newItemPosition].id
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()
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
) = oldItems[oldItemPosition] == oldItems[newItemPosition]
}

View file

@ -0,0 +1,62 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.broadcasts
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
import androidx.lifecycle.Lifecycle.Event.ON_RESUME
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL
typealias SiteCallback = (Site) -> Unit
/** @author Aidan Follestad (@afollestad) */
class StatusUpdateIntentReceiver(
private val app: Application,
private val callback: SiteCallback
) : LifecycleObserver {
internal val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) {
if (intent.action == ACTION_STATUS_UPDATE) {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site
?: return
callback(model)
}
}
}
@OnLifecycleEvent(ON_RESUME)
fun onResume() {
val filter = IntentFilter().apply {
addAction(ACTION_STATUS_UPDATE)
}
app.registerReceiver(intentReceiver, filter)
}
@OnLifecycleEvent(ON_PAUSE)
fun onPause() = app.unregisterReceiver(intentReceiver)
}

View file

@ -19,9 +19,10 @@ import android.app.Application;
import android.app.NotificationManager;
import android.app.job.JobScheduler;
import com.afollestad.nocknock.NockNockApp;
import com.afollestad.nocknock.di.viewmodels.ViewModelModule;
import com.afollestad.nocknock.engine.EngineModule;
import com.afollestad.nocknock.engine.statuscheck.BootReceiver;
import com.afollestad.nocknock.engine.statuscheck.ValidationJob;
import com.afollestad.nocknock.engine.validation.BootReceiver;
import com.afollestad.nocknock.engine.validation.ValidationJob;
import com.afollestad.nocknock.notifications.NotificationsModule;
import com.afollestad.nocknock.ui.addsite.AddSiteActivity;
import com.afollestad.nocknock.ui.main.MainActivity;
@ -35,7 +36,13 @@ import okhttp3.OkHttpClient;
/** @author Aidan Follestad (@afollestad) */
@Singleton
@Component(
modules = {MainModule.class, EngineModule.class, NotificationsModule.class, UtilitiesModule.class}
modules = {
MainModule.class,
ViewModelModule.class,
EngineModule.class,
NotificationsModule.class,
UtilitiesModule.class
}
)
public interface AppComponent {
@ -51,8 +58,7 @@ public interface AppComponent {
void inject(BootReceiver bootReceiver);
@Component.Builder
interface Builder {
@Component.Builder interface Builder {
@BindsInstance
Builder application(Application application);

View file

@ -15,24 +15,21 @@
*/
package com.afollestad.nocknock.di;
import static androidx.room.Room.databaseBuilder;
import android.app.Application;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.data.AppDatabase;
import com.afollestad.nocknock.ui.addsite.AddSitePresenter;
import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter;
import com.afollestad.nocknock.ui.main.MainActivity;
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 com.afollestad.nocknock.utilities.qualifiers.AppIconRes;
import com.afollestad.nocknock.di.qualifiers.IoDispatcher;
import com.afollestad.nocknock.utilities.qualifiers.MainActivityClass;
import dagger.Binds;
import com.afollestad.nocknock.di.qualifiers.MainDispatcher;
import dagger.Module;
import dagger.Provides;
import javax.inject.Singleton;
import kotlinx.coroutines.CoroutineDispatcher;
import kotlinx.coroutines.Dispatchers;
import static androidx.room.Room.databaseBuilder;
/** @author Aidan Follestad (@afollestad) */
@Module
@ -40,18 +37,6 @@ abstract class MainModule {
@SuppressWarnings("FieldCanBeLocal")
private static String DATABASE_NAME = "NockNock.db";
@Binds
@Singleton
abstract MainPresenter provideMainPresenter(RealMainPresenter presenter);
@Binds
@Singleton
abstract AddSitePresenter provideAddSitePresenter(RealAddSitePresenter presenter);
@Binds
@Singleton
abstract ViewSitePresenter provideViewSitePresenter(RealViewSitePresenter presenter);
@Provides
@Singleton
@AppIconRes
@ -71,4 +56,18 @@ abstract class MainModule {
static AppDatabase provideAppDatabase(Application app) {
return databaseBuilder(app, AppDatabase.class, DATABASE_NAME).build();
}
@Provides
@Singleton
@MainDispatcher
static CoroutineDispatcher provideMainDispatcher() {
return Dispatchers.getMain();
}
@Provides
@Singleton
@IoDispatcher
static CoroutineDispatcher provideIoDispatcher() {
return Dispatchers.getIO();
}
}

View file

@ -13,27 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.main
package com.afollestad.nocknock.di.qualifiers
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
import javax.inject.Qualifier
import kotlin.annotation.AnnotationRetention.RUNTIME
/** @author Aidan Follestad (@afollestad) */
interface MainView {
fun setLoading()
fun setDoneLoading()
fun setModels(models: List<Site>)
fun updateModel(model: Site)
fun onSiteDeleted(model: Site)
fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
)
}
@Qualifier
@Retention(RUNTIME)
annotation class IoDispatcher

View file

@ -13,33 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.addsite
package com.afollestad.nocknock.di.qualifiers
import androidx.annotation.StringRes
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
import javax.inject.Qualifier
import kotlin.annotation.AnnotationRetention.RUNTIME
/** @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
)
}
@Qualifier
@Retention(RUNTIME)
annotation class MainDispatcher

View file

@ -13,27 +13,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.utilities.ext
package com.afollestad.nocknock.di.viewmodels
import android.view.View
import androidx.lifecycle.ViewModel
import com.afollestad.nocknock.di.qualifiers.MainDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
import org.jetbrains.annotations.TestOnly
import javax.inject.Inject
typealias ScopeReceiver = CoroutineScope.() -> Unit
abstract class ScopedViewModel : ViewModel() {
fun View.scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) {
val job = Job(context[Job])
@Inject
@MainDispatcher
lateinit var mainDispatcher: CoroutineDispatcher
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = Unit
override fun onViewDetachedFromWindow(v: View) {
job.cancel()
}
})
private val job = Job()
protected val scope = CoroutineScope(job + mainDispatcher)
exec(CoroutineScope(context + job))
override fun onCleared() {
super.onCleared()
job.cancel()
}
@TestOnly open fun destroy() = job.cancel()
}

View file

@ -0,0 +1,48 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.di.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.MapKey
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.FUNCTION
import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
import kotlin.annotation.AnnotationTarget.PROPERTY_SETTER
import kotlin.reflect.KClass
typealias ViewModelMap = MutableMap<Class<out ViewModel>, Provider<ViewModel>>
@Target(FUNCTION, PROPERTY_GETTER, PROPERTY_SETTER)
@Retention(RUNTIME)
@MapKey
annotation class ViewModelKey(val value: KClass<out ViewModel>)
/**
* https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455
*/
@Singleton
class ViewModelFactory @Inject constructor(private val viewModels: ViewModelMap) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return viewModels[modelClass]?.get() as T
}
}

View file

@ -0,0 +1,33 @@
package com.afollestad.nocknock.di.viewmodels;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.afollestad.nocknock.ui.addsite.AddSiteViewModel;
import com.afollestad.nocknock.ui.main.MainViewModel;
import com.afollestad.nocknock.ui.viewsite.ViewSiteViewModel;
import dagger.Binds;
import dagger.Module;
import dagger.multibindings.IntoMap;
/** https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 */
@Module
public abstract class ViewModelModule {
@Binds
abstract ViewModelProvider.Factory bindViewModelFactory(ViewModelFactory factory);
@Binds
@IntoMap
@ViewModelKey(MainViewModel.class)
abstract ViewModel mainViewModel(MainViewModel viewModel);
@Binds
@IntoMap
@ViewModelKey(AddSiteViewModel.class)
abstract ViewModel addSiteViewModel(AddSiteViewModel viewModel);
@Binds
@IntoMap
@ViewModelKey(ViewSiteViewModel.class)
abstract ViewModel viewSiteViewModel(ViewSiteViewModel viewModel);
}

View file

@ -19,21 +19,17 @@ import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.indexToValidationMode
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.attachLiveData
import com.afollestad.nocknock.viewcomponents.ext.conceal
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onLayout
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.textAsInt
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
import com.afollestad.nocknock.viewcomponents.ext.toViewError
import com.afollestad.nocknock.viewcomponents.ext.toViewText
import com.afollestad.nocknock.viewcomponents.ext.toViewVisibility
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
import kotlinx.android.synthetic.main.activity_addsite.inputName
@ -48,7 +44,6 @@ import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.toolbar
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlin.math.max
import kotlin.properties.Delegates.notNull
@ -57,14 +52,19 @@ const val KEY_FAB_Y = "fab_y"
const val KEY_FAB_SIZE = "fab_size"
/** @author Aidan Follestad (@afollestad) */
class AddSiteActivity : AppCompatActivity(), AddSiteView {
class AddSiteActivity : AppCompatActivity() {
var isClosing: Boolean = false
var revealCx by notNull<Int>()
var revealCy by notNull<Int>()
var revealRadius by notNull<Float>()
@Inject lateinit var presenter: AddSitePresenter
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory
internal var isClosing = false
private val viewModel by lazy {
return@lazy ViewModelProviders.of(this, viewModelFactory)
.get(AddSiteViewModel::class.java)
}
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
@ -72,8 +72,70 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView {
injector().injectInto(this)
setContentView(R.layout.activity_addsite)
presenter.takeView(this)
setupUi(savedInstanceState)
lifecycle.addObserver(viewModel)
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
// Name
inputName.attachLiveData(this, viewModel.name)
viewModel.onNameError()
.toViewError(this, inputName)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlError()
.toViewError(this, inputUrl)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
viewModel.onTimeoutError()
.toViewError(this, responseTimeoutInput)
// Validation mode
responseValidationMode.attachLiveData(this, viewModel.validationMode,
{ ValidationMode.fromIndex(it) },
{ it.toIndex() }
)
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
// Validation search term
responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
errorData = viewModel.onValidationScriptError(),
visibility = viewModel.onValidationScriptVisibility()
)
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
errorData = viewModel.onCheckIntervalError()
)
// Done button
doneBtn.setOnClickListener {
viewModel.commit {
setResult(RESULT_OK)
finish()
overridePendingTransition(R.anim.fade_out, R.anim.fade_out)
}
}
}
private fun setupUi(savedInstanceState: Bundle?) {
toolbar.setNavigationOnClickListener { closeActivityWithReveal() }
if (savedInstanceState == null) {
@ -93,115 +155,14 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView {
}
}
inputUrl.setOnFocusChangeListener { _, hasFocus ->
presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
}
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
resources.getStringArray(R.array.response_validation_options)
)
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
doneBtn.setOnClickListener {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
val defaultTimeout = getString(R.string.response_timeout_default).toInt()
isClosing = true
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationArgs = validationMode.validationContent(),
networkTimeout = responseTimeoutInput.textAsInt(defaultValue = defaultTimeout)
)
}
}
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
}
)
responseTimeoutInput.error = if (errors.networkTimeout != null) {
getString(errors.networkTimeout!!)
} 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)
override fun onBackPressed() = closeActivityWithReveal()
private fun ValidationMode.validationContent() = when (this) {
STATUS_CODE -> null
TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
JAVASCRIPT -> scriptInputLayout.getCode()
}
}

View file

@ -15,7 +15,7 @@
*/
package com.afollestad.nocknock.ui.addsite
import android.view.ViewAnimationUtils
import android.view.ViewAnimationUtils.createCircularReveal
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import com.afollestad.nocknock.utilities.ext.onEnd
@ -27,7 +27,7 @@ const val REVEAL_DURATION = 300L
internal fun AddSiteActivity.circularRevealActivity() {
val circularReveal =
ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius)
createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius)
.apply {
duration = REVEAL_DURATION
interpolator = DecelerateInterpolator()
@ -37,9 +37,11 @@ internal fun AddSiteActivity.circularRevealActivity() {
}
internal fun AddSiteActivity.closeActivityWithReveal() {
if (isClosing) return
if (isClosing) {
return
}
isClosing = true
ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, revealRadius, 0f)
createCircularReveal(rootView, revealCx, revealCy, revealRadius, 0f)
.apply {
duration = REVEAL_DURATION
interpolator = AccelerateInterpolator()

View file

@ -1,190 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.addsite
import androidx.annotation.CheckResult
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.putSite
import com.afollestad.nocknock.engine.statuscheck.ValidationManager
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,
var networkTimeout: Int? = null
) {
@CheckResult fun any(): Boolean {
return name != null || url != null || checkInterval != null ||
termSearch != null || javaScript != null || networkTimeout != 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,
validationArgs: String?,
networkTimeout: Int
)
fun dropView()
}
/** @author Aidan Follestad (@afollestad) */
class RealAddSitePresenter @Inject constructor(
private val database: AppDatabase,
private val checkStatusManager: ValidationManager
) : 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,
validationArgs: String?,
networkTimeout: Int
) {
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 && validationArgs.isNullOrEmpty()) {
inputErrors.termSearch = R.string.please_enter_search_term
} else if (validationMode == JAVASCRIPT && validationArgs.isNullOrEmpty()) {
inputErrors.javaScript = R.string.please_enter_javaScript
}
if (networkTimeout <= 0) {
inputErrors.networkTimeout = R.string.please_enter_networkTimeout
}
if (inputErrors.any()) {
view?.setInputErrors(inputErrors)
return
}
val newSettings = SiteSettings(
validationIntervalMs = checkInterval,
validationMode = validationMode,
validationArgs = validationArgs,
networkTimeout = networkTimeout,
disabled = false
)
val newModel = Site(
id = 0,
name = name,
url = url,
settings = newSettings,
lastResult = null
)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
val storedModel = async(IO) {
database.putSite(newModel)
}.await()
checkStatusManager.scheduleCheck(
site = storedModel,
rightNow = true,
cancelPrevious = true
)
setDoneLoading()
onSiteAdded()
}
}
}
}
override fun dropView() {
view = null
}
}

View file

@ -0,0 +1,224 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.addsite
import androidx.annotation.CheckResult
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.putSite
import com.afollestad.nocknock.di.viewmodels.ScopedViewModel
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.di.qualifiers.IoDispatcher
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import com.afollestad.nocknock.viewcomponents.ext.map
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import javax.inject.Inject
/** @author Aidan Follestad (@afollestad) */
class AddSiteViewModel @Inject constructor(
private val database: AppDatabase,
private val validationManager: ValidationManager,
@field:IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(), LifecycleObserver {
// Public properties
val name = MutableLiveData<String>()
val url = MutableLiveData<String>()
val timeout = MutableLiveData<Int>()
val validationMode = MutableLiveData<ValidationMode>()
val validationSearchTerm = MutableLiveData<String>()
val validationScript = MutableLiveData<String>()
val checkIntervalValue = MutableLiveData<Int>()
val checkIntervalUnit = MutableLiveData<Long>()
// Private properties
private val isLoading = MutableLiveData<Boolean>()
private val nameError = MutableLiveData<Int?>()
private val urlError = MutableLiveData<Int?>()
private val timeoutError = MutableLiveData<Int?>()
private val validationSearchTermError = MutableLiveData<Int?>()
private val validationScriptError = MutableLiveData<Int?>()
private val checkIntervalValueError = MutableLiveData<Int?>()
// Expose private properties or calculated properties
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onNameError(): LiveData<Int?> = nameError
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map {
val parsed = HttpUrl.parse(it)
return@map it.isNotEmpty() &&
parsed != null &&
parsed.scheme() != "http" &&
parsed.scheme() != "https"
}
}
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map {
when (it) {
STATUS_CODE -> R.string.validation_mode_status_desc
TERM_SEARCH -> R.string.validation_mode_term_desc
JAVASCRIPT -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unknown validation mode: $it")
}
}
}
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError
@CheckResult fun onValidationSearchTermVisibility() =
validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
@CheckResult fun onValidationScriptVisibility() =
validationMode.map { it == JAVASCRIPT }
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
// Actions
fun commit(done: () -> Unit) {
scope.launch {
val newModel = generateDbModel() ?: return@launch
isLoading.value = true
val storedModel = withContext(ioDispatcher) {
database.putSite(newModel)
}
validationManager.scheduleCheck(
site = storedModel,
rightNow = true,
cancelPrevious = true
)
isLoading.value = false
done()
}
}
// Utilities
private fun getCheckIntervalMs(): Long {
val value = checkIntervalValue.value ?: return 0
val unit = checkIntervalUnit.value ?: return 0
return value * unit
}
private fun getValidationArgs(): String? {
return when (validationMode.value) {
TERM_SEARCH -> validationSearchTerm.value
JAVASCRIPT -> validationScript.value
else -> null
}
}
private fun generateDbModel(): Site? {
var errorCount = 0
// Validation name
if (name.value.isNullOrEmpty()) {
nameError.value = R.string.please_enter_name
errorCount++
} else {
nameError.value = null
}
// Validate URL
when {
url.value.isNullOrEmpty() -> {
urlError.value = R.string.please_enter_url
errorCount++
}
HttpUrl.parse(url.value!!) == null -> {
urlError.value = R.string.please_enter_valid_url
errorCount++
}
else -> {
urlError.value = null
}
}
// Validate timeout
if (timeout.value.isNullOrLessThan(1)) {
timeoutError.value = R.string.please_enter_networkTimeout
errorCount++
} else {
timeoutError.value = null
}
// Validate check interval
if (checkIntervalValue.value.isNullOrLessThan(1)) {
checkIntervalValueError.value = R.string.please_enter_check_interval
errorCount++
} else {
checkIntervalValueError.value = null
}
// Validate arguments
if (validationMode == TERM_SEARCH &&
validationSearchTerm.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = R.string.please_enter_search_term
validationScriptError.value = null
} else if (validationMode == JAVASCRIPT &&
validationScript.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = null
validationScriptError.value = R.string.please_enter_javaScript
} else {
validationSearchTermError.value = null
validationScriptError.value = null
}
if (errorCount > 0) {
return null
}
val newSettings = SiteSettings(
validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!,
validationArgs = getValidationArgs(),
networkTimeout = timeout.value!!,
disabled = false
)
return Site(
id = 0,
name = name.value!!,
url = url.value!!,
settings = newSettings,
lastResult = null
)
}
}

View file

@ -15,12 +15,12 @@
*/
package com.afollestad.nocknock.ui.main
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager
@ -30,43 +30,58 @@ import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.ServerAdapter
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.dialogs.AboutDialog
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list
import kotlinx.android.synthetic.main.activity_main.loadingProgress
import kotlinx.android.synthetic.main.activity_main.rootView
import kotlinx.android.synthetic.main.activity_main.toolbar
import kotlinx.android.synthetic.main.include_empty_view.emptyText
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
class MainActivity : AppCompatActivity(), MainView {
class MainActivity : AppCompatActivity() {
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) = presenter.onBroadcast(intent)
}
@Inject lateinit var presenter: MainPresenter
@Inject lateinit var notificationManager: NockNotificationManager
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory
private lateinit var adapter: ServerAdapter
internal val viewModel by lazy {
return@lazy ViewModelProviders.of(this, viewModelFactory)
.get(MainViewModel::class.java)
}
private val statusUpdateReceiver =
StatusUpdateIntentReceiver(application) {
viewModel.postSiteUpdate(it)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_main)
presenter.takeView(this)
setupUi()
notificationManager.createChannels()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
viewModel.onSites()
.observe(this, Observer {
adapter.set(it)
emptyText.showOrHide(it.isEmpty())
})
loadingProgress.observe(this, viewModel.onIsLoading())
processIntent(intent)
}
private fun setupUi() {
toolbar.inflateMenu(R.menu.menu_main)
toolbar.setOnMenuItemClickListener { item ->
if (item.itemId == R.id.about) {
@ -82,8 +97,6 @@ class MainActivity : AppCompatActivity(), MainView {
list.addItemDecoration(DividerItemDecoration(this, VERTICAL))
fab.setOnClickListener { addSite() }
processIntent(intent)
}
override fun onNewIntent(intent: Intent?) {
@ -91,52 +104,6 @@ class MainActivity : AppCompatActivity(), MainView {
intent?.let(::processIntent)
}
override fun onResume() {
super.onResume()
val filter = IntentFilter().apply {
addAction(ACTION_STATUS_UPDATE)
}
safeRegisterReceiver(intentReceiver, filter)
presenter.resume()
}
override fun onPause() {
super.onPause()
safeUnregisterReceiver(intentReceiver)
}
override fun onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun setLoading() = loadingProgress.setLoading()
override fun setDoneLoading() = loadingProgress.setDone()
override fun setModels(models: List<Site>) {
list.post {
adapter.set(models)
emptyText.showOrHide(models.isEmpty())
}
}
override fun updateModel(model: Site) {
list.post { adapter.update(model) }
}
override fun onSiteDeleted(model: Site) {
list.post {
adapter.remove(model)
emptyText.showOrHide(adapter.itemCount == 0)
}
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
private fun onSiteSelected(
model: Site,
longClick: Boolean
@ -146,7 +113,7 @@ class MainActivity : AppCompatActivity(), MainView {
title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
0 -> presenter.refreshSite(model)
0 -> viewModel.refreshSite(model)
1 -> maybeRemoveSite(model)
}
}

View file

@ -24,7 +24,7 @@ import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.addsite.KEY_FAB_SIZE
import com.afollestad.nocknock.ui.addsite.KEY_FAB_X
import com.afollestad.nocknock.ui.addsite.KEY_FAB_Y
import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL
import com.afollestad.nocknock.ui.viewsite.KEY_SITE
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL
import kotlinx.android.synthetic.main.activity_main.fab
@ -53,14 +53,14 @@ internal fun MainActivity.viewSite(model: Site) {
private fun MainActivity.intentToView(model: Site) =
Intent(this, ViewSiteActivity::class.java).apply {
putExtra(KEY_VIEW_MODEL, model)
putExtra(KEY_SITE, model)
}
internal fun MainActivity.maybeRemoveSite(model: Site) {
MaterialDialog(this).show {
title(R.string.remove_site)
message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
positiveButton(R.string.remove) { presenter.removeSite(model) }
positiveButton(R.string.remove) { viewModel.removeSite(model) }
negativeButton(android.R.string.cancel)
}
}

View file

@ -1,150 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.main
import android.app.Application
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.allSites
import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.legacy.DbMigrator
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.ValidationManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject
import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */
interface MainPresenter {
fun takeView(view: MainView)
fun onBroadcast(intent: Intent)
fun resume()
fun refreshSite(site: Site)
fun removeSite(site: Site)
fun dropView()
}
/** @author Aidan Follestad (@afollestad) */
class RealMainPresenter @Inject constructor(
private val app: Application,
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val checkStatusManager: ValidationManager
) : MainPresenter {
private var view: MainView? = null
override fun takeView(view: MainView) {
this.view = view
notificationManager.createChannels()
ensureCheckJobs()
}
override fun onBroadcast(intent: Intent) {
if (intent.action == ACTION_STATUS_UPDATE) {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site
?: return
view?.updateModel(model)
}
}
override fun resume() {
notificationManager.cancelStatusNotifications()
view!!.run {
setModels(listOf())
setLoading()
scopeWhileAttached(Main) {
launch(coroutineContext) {
doMigrationIfNeeded()
val models = async(IO) { database.allSites() }.await()
setModels(models)
setDoneLoading()
}
}
}
}
override fun refreshSite(site: Site) =
checkStatusManager.scheduleCheck(
site = site,
rightNow = true,
cancelPrevious = true
)
override fun removeSite(site: Site) {
checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
view!!.scopeWhileAttached(Main) {
launch(coroutineContext) {
async(IO) { database.deleteSite(site) }.await()
view?.onSiteDeleted(site)
}
}
}
override fun dropView() {
view = null
}
private suspend fun CoroutineScope.doMigrationIfNeeded() {
if (needDbMigration()) {
log("Doing database migration...")
val migratedCount = async(IO) {
DbMigrator(app, database).migrateAll()
}.await()
didDbMigration()
log("Database migration done! Migrated $migratedCount models.")
ensureCheckJobs()
}
}
private fun needDbMigration(): Boolean =
!app.getSharedPreferences("settings", MODE_PRIVATE)
.getBoolean("did_db_migration", false)
private fun didDbMigration() =
app.getSharedPreferences("settings", MODE_PRIVATE)
.edit()
.putBoolean("did_db_migration", true)
.apply()
private fun ensureCheckJobs() {
view!!.scopeWhileAttached(IO) {
launch(coroutineContext) {
checkStatusManager.ensureScheduledChecks()
}
}
}
}

View file

@ -0,0 +1,113 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.main
import androidx.annotation.CheckResult
import androidx.lifecycle.Lifecycle.Event.ON_RESUME
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.allSites
import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.di.viewmodels.ScopedViewModel
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.di.qualifiers.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
/** @author Aidan Follestad (@afollestad) */
class MainViewModel @Inject constructor(
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val validationManager: ValidationManager,
@field:IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(), LifecycleObserver {
private val sites = MutableLiveData<List<Site>>()
private val isLoading = MutableLiveData<Boolean>()
@CheckResult fun onSites(): LiveData<List<Site>> = sites
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@OnLifecycleEvent(ON_RESUME)
fun onResume() = loadSites()
fun postSiteUpdate(model: Site) {
val currentSites = sites.value ?: return
val index = currentSites.indexOfFirst { it.id == model.id }
if (index == -1) return
sites.value = currentSites.toMutableList()
.apply {
this[index] = model
}
}
fun refreshSite(model: Site) {
validationManager.scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true
)
}
fun removeSite(model: Site) {
validationManager.cancelCheck(model)
notificationManager.cancelStatusNotification(model)
scope.launch {
isLoading.value = true
withContext(ioDispatcher) { database.deleteSite(model) }
val currentSites = sites.value ?: return@launch
val index = currentSites.indexOfFirst { it.id == model.id }
isLoading.value = false
if (index == -1) return@launch
sites.value = currentSites.toMutableList()
.apply {
removeAt(index)
}
}
}
private fun loadSites() {
scope.launch {
notificationManager.cancelStatusNotifications()
sites.value = listOf()
isLoading.value = true
val result = withContext(ioDispatcher) {
database.allSites()
}
sites.value = result
ensureCheckJobs()
isLoading.value = false
}
}
private suspend fun ensureCheckJobs() {
withContext(ioDispatcher) {
validationManager.ensureScheduledChecks()
}
}
}

View file

@ -16,35 +16,24 @@
package com.afollestad.nocknock.ui.viewsite
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.indexToValidationMode
import com.afollestad.nocknock.data.model.textRes
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.attachLiveData
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.textAsInt
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
import com.afollestad.nocknock.viewcomponents.ext.toViewError
import com.afollestad.nocknock.viewcomponents.ext.toViewText
import com.afollestad.nocknock.viewcomponents.ext.toViewVisibility
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
@ -55,7 +44,6 @@ import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress
import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_viewsite.rootView
import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_viewsite.scrollView
import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult
@ -63,21 +51,21 @@ 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 java.lang.System.currentTimeMillis
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
class ViewSiteActivity : AppCompatActivity() {
@Inject lateinit var presenter: ViewSitePresenter
@Inject lateinit var viewModelFactory: ViewModelProvider.Factory
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) = presenter.onBroadcast(intent)
internal val viewModel by lazy {
return@lazy ViewModelProviders.of(this, viewModelFactory)
.get(ViewSiteViewModel::class.java)
}
private val statusUpdateReceiver =
StatusUpdateIntentReceiver(application) {
viewModel.setModel(it)
}
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
@ -85,18 +73,102 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
injector().injectInto(this)
setContentView(R.layout.activity_viewsite)
setupUi()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
// Status
viewModel.status.observe(this, Observer {
iconStatus.setStatus(it)
invalidateMenuForStatus(it)
})
// Name
inputName.attachLiveData(this, viewModel.name)
viewModel.onNameError()
.toViewError(this, inputName)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlError()
.toViewError(this, inputUrl)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
viewModel.onTimeoutError()
.toViewError(this, responseTimeoutInput)
// Validation mode
responseValidationMode.attachLiveData(this, viewModel.validationMode,
{ ValidationMode.fromIndex(it) },
{ it.toIndex() })
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
// Validation search term
responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
errorData = viewModel.onValidationScriptError(),
visibility = viewModel.onValidationScriptVisibility()
)
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
errorData = viewModel.onCheckIntervalError()
)
// Last/next check
viewModel.onLastCheckResultText()
.toViewText(this, textLastCheckResult)
viewModel.onNextCheckText()
.toViewText(this, textNextCheck)
// Disabled button
viewModel.onDisableChecksVisibility()
.toViewVisibility(this, disableChecksButton)
disableChecksButton.setOnClickListener { maybeDisableChecks() }
// Done button
viewModel.onDoneButtonText()
.toViewText(this, doneBtn)
doneBtn.setOnClickListener {
viewModel.commit { finish() }
}
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(model)
}
private fun setupUi() {
toolbar.run {
setNavigationOnClickListener { finish() }
inflateMenu(R.menu.menu_viewsite)
menu.findItem(R.id.refresh)
.setActionView(R.layout.menu_item_refresh_icon)
.apply {
actionView.setOnClickListener { presenter.checkNow() }
actionView.setOnClickListener { viewModel.checkNow() }
}
setOnMenuItemClickListener {
maybeRemoveSite()
return@setOnMenuItemClickListener true
true
}
}
@ -108,10 +180,6 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
}
}
inputUrl.setOnFocusChangeListener { _, hasFocus ->
presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
}
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
@ -119,164 +187,13 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
)
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
doneBtn.setOnClickListener {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
val defaultTimeout = getString(R.string.response_timeout_default).toInt()
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationArgs = validationMode.validationContent(),
networkTimeout = responseTimeoutInput.textAsInt(defaultValue = defaultTimeout)
)
}
disableChecksButton.setOnClickListener { maybeDisableChecks() }
presenter.takeView(this, intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
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)
if (intent != null && intent.hasExtra(KEY_SITE)) {
val newModel = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(newModel)
}
}
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: Site) = with(model) {
val siteSettings = this.settings
requireNotNull(siteSettings) { "Site settings must be populated." }
iconStatus.setStatus(this.lastResult?.status ?: WAITING)
inputName.setText(this.name)
inputUrl.setText(this.url)
if (this.lastResult == null) {
textLastCheckResult.setText(R.string.none)
} else {
val statusText = this.lastResult!!.status.textRes()
textLastCheckResult.text = if (statusText == 0) {
this.lastResult!!.reason
} else {
getString(statusText)
}
}
if (siteSettings.disabled) {
textNextCheck.setText(R.string.auto_checks_disabled)
} else {
val lastCheck = this.lastResult?.timestampMs ?: currentTimeMillis()
textNextCheck.text = (lastCheck + siteSettings.validationIntervalMs).formatDate()
}
checkIntervalLayout.set(siteSettings.validationIntervalMs)
responseValidationMode.setSelection(siteSettings.validationMode.value - 1)
when (siteSettings.validationMode) {
TERM_SEARCH -> responseValidationSearchTerm.setText(siteSettings.validationArgs ?: "")
JAVASCRIPT -> scriptInputLayout.setCode(siteSettings.validationArgs)
else -> {
responseValidationSearchTerm.setText("")
scriptInputLayout.clear()
}
}
responseTimeoutInput.setText(siteSettings.networkTimeout.toString())
disableChecksButton.showOrHide(!siteSettings.disabled)
doneBtn.setText(
if (siteSettings.disabled) R.string.renable_and_save_changes
else R.string.save_changes
)
invalidateMenuForStatus(model)
}
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
}
)
responseTimeoutInput.error = if (errors.networkTimeout != null) {
getString(errors.networkTimeout!!)
} else {
null
}
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
override fun onResume() {
super.onResume()
val filter = IntentFilter().apply {
addAction(ACTION_STATUS_UPDATE)
}
safeRegisterReceiver(intentReceiver, filter)
}
override fun onPause() {
super.onPause()
safeUnregisterReceiver(intentReceiver)
}
private fun ValidationMode.validationContent() = when (this) {
STATUS_CODE -> null
TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
JAVASCRIPT -> scriptInputLayout.getCode()
}
}

View file

@ -18,39 +18,42 @@ package com.afollestad.nocknock.ui.viewsite
import android.widget.ImageView
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.isPending
import com.afollestad.nocknock.toHtml
import com.afollestad.nocknock.utilities.ext.animateRotation
import kotlinx.android.synthetic.main.activity_viewsite.toolbar
const val KEY_SITE = "site_model"
internal fun ViewSiteActivity.maybeRemoveSite() {
val model = presenter.currentModel()
val model = viewModel.site
MaterialDialog(this).show {
title(R.string.remove_site)
message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
positiveButton(R.string.remove) { presenter.removeSite() }
positiveButton(R.string.remove) {
viewModel.removeSite { finish() }
}
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.maybeDisableChecks() {
val model = presenter.currentModel()
val model = viewModel.site
MaterialDialog(this).show {
title(R.string.disable_automatic_checks)
message(
text = context.getString(R.string.disable_automatic_checks_prompt, model.name).toHtml()
)
positiveButton(R.string.disable) { presenter.disableChecks() }
positiveButton(R.string.disable) { viewModel.disable() }
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.invalidateMenuForStatus(model: Site) {
internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) {
val refreshIcon = toolbar.menu.findItem(R.id.refresh)
.actionView as ImageView
if (model.lastResult?.status.isPending()) {
if (status.isPending()) {
refreshIcon.animateRotation()
} else {
refreshIcon.run {

View file

@ -1,301 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.viewsite
import android.content.Intent
import androidx.annotation.CheckResult
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.updateSite
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.ValidationManager
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,
var networkTimeout: Int? = null
) {
@CheckResult fun any(): Boolean {
return name != null || url != null || checkInterval != null ||
termSearch != null || javaScript != null || networkTimeout != 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,
validationArgs: String?,
networkTimeout: Int
)
fun checkNow()
fun disableChecks()
fun removeSite()
fun currentModel(): Site
fun dropView()
}
/** @author Aidan Follestad (@afollestad) */
class RealViewSitePresenter @Inject constructor(
private val database: AppDatabase,
private val checkStatusManager: ValidationManager,
private val notificationManager: NockNotificationManager
) : ViewSitePresenter {
private var view: ViewSiteView? = null
private var currentModel: Site? = null
override fun takeView(
view: ViewSiteView,
intent: Intent
) {
this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as Site
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? Site
?: return
this.currentModel = model
view?.displayModel(model)
}
}
override fun onNewIntent(intent: Intent?) {
if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) {
currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as Site
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,
validationArgs: String?,
networkTimeout: Int
) {
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 && validationArgs.isNullOrEmpty()) {
inputErrors.termSearch = R.string.please_enter_search_term
} else if (validationMode == JAVASCRIPT && validationArgs.isNullOrEmpty()) {
inputErrors.javaScript = R.string.please_enter_javaScript
}
if (networkTimeout <= 0) {
inputErrors.networkTimeout = R.string.please_enter_networkTimeout
}
if (inputErrors.any()) {
view?.setInputErrors(inputErrors)
return
}
val updatedSettings = currentModel!!.settings!!.copy(
validationIntervalMs = checkInterval,
validationMode = validationMode,
validationArgs = validationArgs,
disabled = false,
networkTimeout = networkTimeout
)
val updatedModel = currentModel!!.copy(
name = name,
url = url,
settings = updatedSettings
)
.withStatus(status = WAITING)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
async(IO) {
database.updateSite(updatedModel)
}.await()
checkStatusManager.scheduleCheck(
site = updatedModel,
rightNow = true,
cancelPrevious = true
)
setDoneLoading()
view?.finish()
}
}
}
}
override fun checkNow() = with(view!!) {
val checkModel = currentModel!!.withStatus(
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(
settings = currentModel!!.settings!!.copy(
disabled = true
)
)
async(IO) {
database.updateSite(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) {
database.deleteSite(site)
}.await()
setDoneLoading()
view?.finish()
}
}
}
}
override fun currentModel() = this.currentModel!!
override fun dropView() {
view = null
currentModel = null
}
@TestOnly fun setModel(model: Site) {
this.currentModel = model
}
}

View file

@ -0,0 +1,320 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.viewsite
import android.app.Application
import androidx.annotation.CheckResult
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.data.model.textRes
import com.afollestad.nocknock.data.updateSite
import com.afollestad.nocknock.di.viewmodels.ScopedViewModel
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.di.qualifiers.IoDispatcher
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import com.afollestad.nocknock.viewcomponents.ext.map
import com.afollestad.nocknock.viewcomponents.ext.zip
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.lang.System.currentTimeMillis
import javax.inject.Inject
/** @author Aidan Follestad (@afollestad) */
class ViewSiteViewModel @Inject constructor(
private val app: Application,
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val validationManager: ValidationManager,
@field:IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(), LifecycleObserver {
lateinit var site: Site
// Public properties
val status = MutableLiveData<Status>()
val name = MutableLiveData<String>()
val url = MutableLiveData<String>()
val timeout = MutableLiveData<Int>()
val validationMode = MutableLiveData<ValidationMode>()
val validationSearchTerm = MutableLiveData<String>()
val validationScript = MutableLiveData<String>()
val checkIntervalValue = MutableLiveData<Int>()
val checkIntervalUnit = MutableLiveData<Long>()
internal val disabled = MutableLiveData<Boolean>()
internal val lastResult = MutableLiveData<ValidationResult?>()
// Private properties
private val isLoading = MutableLiveData<Boolean>()
private val nameError = MutableLiveData<Int?>()
private val urlError = MutableLiveData<Int?>()
private val timeoutError = MutableLiveData<Int?>()
private val validationSearchTermError = MutableLiveData<Int?>()
private val validationScriptError = MutableLiveData<Int?>()
private val checkIntervalValueError = MutableLiveData<Int?>()
// Expose private properties or calculated properties
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onNameError(): LiveData<Int?> = nameError
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map {
val parsed = HttpUrl.parse(it)
return@map it.isNotEmpty() &&
parsed != null &&
parsed.scheme() != "http" &&
parsed.scheme() != "https"
}
}
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map {
when (it) {
STATUS_CODE -> R.string.validation_mode_status_desc
TERM_SEARCH -> R.string.validation_mode_term_desc
JAVASCRIPT -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unknown validation mode: $it")
}
}
}
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError
@CheckResult fun onValidationSearchTermVisibility() =
validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
@CheckResult fun onValidationScriptVisibility() =
validationMode.map { it == JAVASCRIPT }
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
@CheckResult fun onDisableChecksVisibility(): LiveData<Boolean> =
disabled.map { !it }
@CheckResult fun onDoneButtonText(): LiveData<Int> =
disabled.map {
if (it) R.string.renable_and_save_changes
else R.string.save_changes
}
@CheckResult fun onLastCheckResultText(): LiveData<String> = lastResult.map {
if (it == null) {
app.getString(R.string.none)
} else {
val statusText = it.status.textRes()
if (statusText == 0) {
it.reason
} else {
app.getString(statusText)
}
}
}
@CheckResult fun onNextCheckText(): LiveData<String> {
return zip(disabled, lastResult)
.map {
val disabled = it.first
val lastResult = it.second
if (disabled) {
app.getString(R.string.auto_checks_disabled)
} else {
val lastCheck = lastResult?.timestampMs ?: currentTimeMillis()
(lastCheck + getCheckIntervalMs()).formatDate()
}
}
}
// Actions
fun commit(done: () -> Unit) {
scope.launch {
val updatedModel = getUpdatedDbModel() ?: return@launch
isLoading.value = true
withContext(ioDispatcher) {
database.updateSite(updatedModel)
}
validationManager.scheduleCheck(
site = updatedModel,
rightNow = true,
cancelPrevious = true
)
isLoading.value = false
done()
}
}
fun checkNow() {
val checkModel = site.withStatus(
status = WAITING
)
setModel(checkModel)
validationManager.scheduleCheck(
site = checkModel,
rightNow = true,
cancelPrevious = true
)
}
fun removeSite(done: () -> Unit) {
validationManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
isLoading.value = true
withContext(ioDispatcher) {
database.deleteSite(site)
}
isLoading.value = false
done()
}
}
fun disable() {
validationManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
isLoading.value = true
val newModel = site.copy(
settings = site.settings!!.copy(
disabled = true
)
)
withContext(ioDispatcher) {
database.updateSite(newModel)
}
isLoading.value = false
setModel(newModel)
}
}
// Utilities
private fun getCheckIntervalMs(): Long {
val value = checkIntervalValue.value ?: return 0
val unit = checkIntervalUnit.value ?: return 0
return value * unit
}
private fun getValidationArgs(): String? {
return when (validationMode.value) {
TERM_SEARCH -> validationSearchTerm.value
JAVASCRIPT -> validationScript.value
else -> null
}
}
private fun getUpdatedDbModel(): Site? {
var errorCount = 0
// Validation name
if (name.value.isNullOrEmpty()) {
nameError.value = R.string.please_enter_name
errorCount++
} else {
nameError.value = null
}
// Validate URL
when {
url.value.isNullOrEmpty() -> {
urlError.value = R.string.please_enter_url
errorCount++
}
HttpUrl.parse(url.value!!) == null -> {
urlError.value = R.string.please_enter_valid_url
errorCount++
}
else -> {
urlError.value = null
}
}
// Validate timeout
if (timeout.value.isNullOrLessThan(1)) {
timeoutError.value = R.string.please_enter_networkTimeout
errorCount++
} else {
timeoutError.value = null
}
// Validate check interval
if (checkIntervalValue.value.isNullOrLessThan(1)) {
checkIntervalValueError.value = R.string.please_enter_check_interval
errorCount++
} else {
checkIntervalValueError.value = null
}
// Validate arguments
if (validationMode == TERM_SEARCH &&
validationSearchTerm.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = R.string.please_enter_search_term
validationScriptError.value = null
} else if (validationMode == JAVASCRIPT &&
validationScript.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = null
validationScriptError.value = R.string.please_enter_javaScript
} else {
validationSearchTermError.value = null
validationScriptError.value = null
}
if (errorCount > 0) {
return null
}
val newSettings = site.settings!!.copy(
validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!,
validationArgs = getValidationArgs(),
networkTimeout = timeout.value!!,
disabled = false
)
return site.copy(
name = name.value!!,
url = url.value!!,
settings = newSettings
)
.withStatus(status = WAITING)
}
}

View file

@ -0,0 +1,98 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.viewsite
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.WEEK
import kotlin.math.ceil
fun ViewSiteViewModel.setModel(site: Site) {
requireNotNull(site.settings) {
"Settings must be populated!"
}
this.site = site
val settings = site.settings!!
status.value = site.lastResult?.status ?: WAITING
name.value = site.name
url.value = site.url
timeout.value = settings.networkTimeout
validationMode.value = settings.validationMode
when (settings.validationMode) {
TERM_SEARCH -> {
validationSearchTerm.value = settings.validationArgs
validationScript.value = null
}
JAVASCRIPT -> {
validationSearchTerm.value = null
validationScript.value = settings.validationArgs
}
else -> {
validationSearchTerm.value = null
validationScript.value = null
}
}
setCheckInterval(settings.validationIntervalMs)
this.disabled.value = settings.disabled
this.lastResult.value = site.lastResult
}
private fun ViewSiteViewModel.setCheckInterval(interval: Long) {
when {
interval >= WEEK -> {
checkIntervalValue.value =
getIntervalFromUnit(interval, WEEK)
checkIntervalUnit.value = WEEK
}
interval >= DAY -> {
checkIntervalValue.value =
getIntervalFromUnit(interval, DAY)
checkIntervalUnit.value = DAY
}
interval >= HOUR -> {
checkIntervalValue.value =
getIntervalFromUnit(interval, HOUR)
checkIntervalUnit.value = HOUR
}
interval >= MINUTE -> {
checkIntervalValue.value =
getIntervalFromUnit(interval, MINUTE)
checkIntervalUnit.value = MINUTE
}
else -> {
checkIntervalValue.value = 0
checkIntervalUnit.value = MINUTE
}
}
}
private fun getIntervalFromUnit(
millis: Long,
unit: Long
): Int {
val intervalFloat = millis.toFloat()
val byFloat = unit.toFloat()
return ceil(intervalFloat / byFloat).toInt()
}

View file

@ -87,13 +87,13 @@
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:visibility="gone"
tools:text="Warning: this app checks for server availability with HTTP requests. It's recommended that you use an HTTP URL."
android:text="@string/warning_http_url"
style="@style/NockText.Footnote"
/>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.CheckIntervalLayout
<com.afollestad.nocknock.viewcomponents.interval.CheckIntervalLayout
android:id="@+id/checkIntervalLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -151,7 +151,7 @@
style="@style/NockText.Body.Light"
/>
<com.afollestad.nocknock.viewcomponents.JavaScriptInputLayout
<com.afollestad.nocknock.viewcomponents.js.JavaScriptInputLayout
android:id="@+id/scriptInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -115,7 +115,7 @@
android:layout_marginTop="@dimen/content_inset_less"
/>
<com.afollestad.nocknock.viewcomponents.CheckIntervalLayout
<com.afollestad.nocknock.viewcomponents.interval.CheckIntervalLayout
android:id="@+id/checkIntervalLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -181,7 +181,7 @@
style="@style/NockText.Body.Light"
/>
<com.afollestad.nocknock.viewcomponents.JavaScriptInputLayout
<com.afollestad.nocknock.viewcomponents.js.JavaScriptInputLayout
android:id="@+id/scriptInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View file

@ -1,285 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.statuscheck.ValidationManager
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 database = mockDatabase()
private val checkStatusManager = mock<ValidationManager>()
private val view = mock<AddSiteView>()
private val presenter = RealAddSitePresenter(
database,
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,
60000
)
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,
60000
)
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,
60000
)
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,
60000
)
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,
60000
)
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_networkTimeout_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
STATUS_CODE,
null,
0
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.networkTimeout).isEqualTo(R.string.please_enter_networkTimeout)
}
@Test fun commit_javaScript_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
JAVASCRIPT,
null,
60000
)
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,
60000
)
val siteCaptor = argumentCaptor<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>()
verify(view).setLoading()
verify(database.siteDao()).insert(siteCaptor.capture())
verify(database.siteSettingsDao()).insert(settingsCaptor.capture())
verify(database.validationResultsDao(), never()).insert(any())
val settings = settingsCaptor.firstValue
val model = siteCaptor.firstValue.copy(
id = 1, // fill it in because our insert captor doesn't catch this
settings = settings,
lastResult = null
)
verify(view, never()).setInputErrors(any())
verify(checkStatusManager).scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true,
fromFinishingJob = false
)
verify(view).setDoneLoading()
verify(view).onSiteAdded()
}
}

View file

@ -0,0 +1,33 @@
package com.afollestad.nocknock
import androidx.annotation.CheckResult
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import com.google.common.truth.Truth.assertWithMessage
/** @author Aidan Follestad (@afollestad) */
class TestLiveData<T>(data: LiveData<T>) {
private val receivedValues = mutableListOf<T>()
private val observer = Observer<T> { receivedValues.add(it) }
init {
data.observeForever(observer)
}
fun assertNoValues() {
assertWithMessage("Expected no values, but got: $receivedValues").that(receivedValues)
.isEmpty()
}
fun assertValues(vararg assertValues: T) {
val assertList = assertValues.toList()
assertWithMessage("Expected: $assertList, but got: $receivedValues").that(receivedValues)
.isEqualTo(assertList)
receivedValues.clear()
}
@CheckResult fun values(): List<T> = receivedValues
}
@CheckResult fun <T> LiveData<T>.test() = TestLiveData(this)

View file

@ -1,129 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock
import android.app.Application
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.SharedPreferences
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.ValidationManager
import com.afollestad.nocknock.notifications.NockNotificationManager
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
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class MainPresenterTest {
private val prefs = mock<SharedPreferences> {
on { getBoolean("did_db_migration", false) } doReturn true
}
private val app = mock<Application> {
on { getSharedPreferences("settings", MODE_PRIVATE) } doReturn prefs
}
private val database = mockDatabase()
private val notificationManager = mock<NockNotificationManager>()
private val checkStatusManager = mock<ValidationManager>()
private val view = mock<MainView>()
private val presenter = RealMainPresenter(
app,
database,
notificationManager,
checkStatusManager
)
@Before fun setup() {
doAnswer {
val exec = it.getArgument<ScopeReceiver>(1)
runBlocking { exec() }
Unit
}.whenever(view)
.scopeWhileAttached(any(), any())
presenter.takeView(view)
}
@After fun destroy() {
presenter.dropView()
}
@Test fun onBroadcast() {
val badIntent = fakeIntent("Hello World")
presenter.onBroadcast(badIntent)
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
.doReturn(MOCK_MODEL_2)
presenter.onBroadcast(goodIntent)
verify(view, times(1)).updateModel(MOCK_MODEL_2)
}
@Test fun resume() = runBlocking {
presenter.resume()
verify(notificationManager).cancelStatusNotifications()
val modelsCaptor = argumentCaptor<List<Site>>()
verify(view, times(2)).setModels(modelsCaptor.capture())
assertThat(modelsCaptor.firstValue).isEmpty()
assertThat(modelsCaptor.lastValue).isEqualTo(ALL_MOCK_MODELS)
}
@Test fun refreshSite() {
presenter.refreshSite(MOCK_MODEL_3)
verify(checkStatusManager).scheduleCheck(
site = MOCK_MODEL_3,
rightNow = true,
cancelPrevious = true
)
}
@Test fun removeSite() = runBlocking {
presenter.removeSite(MOCK_MODEL_1)
verify(checkStatusManager).cancelCheck(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
verify(database.siteDao()).delete(MOCK_MODEL_1)
verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
verify(view).onSiteDeleted(MOCK_MODEL_1)
}
private fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
}

View file

@ -15,6 +15,7 @@
*/
package com.afollestad.nocknock
import android.content.Intent
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.SiteDao
import com.afollestad.nocknock.data.SiteSettingsDao
@ -32,6 +33,12 @@ import com.nhaarman.mockitokotlin2.isA
import com.nhaarman.mockitokotlin2.mock
import java.lang.System.currentTimeMillis
fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
fun fakeSettingsModel(
id: Long,
validationMode: ValidationMode = STATUS_CODE

View file

@ -1,420 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock
import android.content.Intent
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status.ERROR
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.ValidationManager
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
import java.lang.System.currentTimeMillis
class ViewSitePresenterTest {
private val database = mockDatabase()
private val checkStatusManager = mock<ValidationManager>()
private val notificationManager = mock<NockNotificationManager>()
private val view = mock<ViewSiteView>()
private val presenter = RealViewSitePresenter(
database,
checkStatusManager,
notificationManager
)
@Before fun setup() {
doAnswer {
val exec = it.getArgument<ScopeReceiver>(1)
runBlocking { exec() }
Unit
}.whenever(view)
.scopeWhileAttached(any(), any())
val intent = fakeIntent("")
whenever(intent.getSerializableExtra(KEY_VIEW_MODEL))
.doReturn(MOCK_MODEL_1)
presenter.takeView(view, intent)
assertThat(presenter.currentModel()).isEqualTo(MOCK_MODEL_1)
verify(view, times(1)).displayModel(MOCK_MODEL_1)
}
@After fun destroy() {
presenter.dropView()
}
@Test fun onBroadcast() {
val badIntent = fakeIntent("Hello World")
presenter.onBroadcast(badIntent)
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
.doReturn(MOCK_MODEL_2)
presenter.onBroadcast(goodIntent)
assertThat(presenter.currentModel()).isEqualTo(MOCK_MODEL_2)
verify(view, times(1)).displayModel(MOCK_MODEL_2)
}
@Test fun onNewIntent() {
val badIntent = fakeIntent(ACTION_STATUS_UPDATE)
presenter.onBroadcast(badIntent)
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_VIEW_MODEL))
.doReturn(MOCK_MODEL_3)
presenter.onBroadcast(goodIntent)
verify(view, times(1)).displayModel(MOCK_MODEL_3)
}
@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,
60000
)
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,
60000
)
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,
60000
)
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,
60000
)
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,
60000
)
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,
60000
)
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_networkTimeout_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
STATUS_CODE,
null,
0
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.networkTimeout).isEqualTo(R.string.please_enter_networkTimeout)
}
@Test fun commit_success() = runBlocking {
val name = "Testing"
val url = "https://hello.com"
val checkInterval = 60000L
val validationMode = TERM_SEARCH
val validationArgs = "Hello World"
val currentModel = presenter.currentModel()
val initialLastResult = ValidationResult(
siteId = currentModel.id,
timestampMs = currentTimeMillis() - 60000,
status = ERROR,
reason = "Oh no!"
)
val disabledModel = currentModel.copy(
settings = currentModel.settings!!.copy(disabled = true),
lastResult = initialLastResult
)
presenter.setModel(disabledModel)
presenter.commit(
name,
url,
checkInterval,
validationMode,
validationArgs,
60000
)
val siteCaptor = argumentCaptor<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>()
val resultCaptor = argumentCaptor<ValidationResult>()
verify(view).setLoading()
verify(database.siteDao()).update(siteCaptor.capture())
verify(database.siteSettingsDao()).update(settingsCaptor.capture())
verify(database.validationResultsDao()).update(resultCaptor.capture())
val model = siteCaptor.firstValue
model.apply {
assertThat(this.name).isEqualTo(name)
assertThat(this.url).isEqualTo(url)
}
val settings = settingsCaptor.firstValue
settings.apply {
assertThat(this.validationIntervalMs).isEqualTo(checkInterval)
assertThat(this.validationArgs).isEqualTo(validationArgs)
assertThat(this.disabled).isFalse()
}
val result = resultCaptor.firstValue
result.apply {
assertThat(this.status).isEqualTo(WAITING)
assertThat(this.reason).isNull()
assertThat(this.timestampMs).isGreaterThan(0)
}
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()
.withStatus(status = WAITING)
presenter.checkNow()
verify(view, never()).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<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>()
val resultCaptor = argumentCaptor<ValidationResult>()
verify(database.siteDao()).update(modelCaptor.capture())
verify(database.siteSettingsDao()).update(settingsCaptor.capture())
verify(database.validationResultsDao()).update(resultCaptor.capture())
val newModel = modelCaptor.firstValue
val newSettings = settingsCaptor.firstValue
val result = resultCaptor.firstValue
assertThat(newSettings.disabled).isTrue()
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(database.siteSettingsDao()).delete(model.settings!!)
verify(database.validationResultsDao()).delete(model.lastResult!!)
verify(database.siteDao()).delete(model)
verify(view).setDoneLoading()
verify(view).finish()
}
private fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
}

View file

@ -0,0 +1,55 @@
package com.afollestad.nocknock.broadcasts
import android.app.Application
import android.content.IntentFilter
import com.afollestad.nocknock.MOCK_MODEL_2
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.fakeIntent
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import org.junit.Test
/** @author Aidan Follestad (@afollestad) */
class StatusUpdateIntentReceiverTest {
private val app = mock<Application>()
private val callback = mock<SiteCallback>()
private val receiver = StatusUpdateIntentReceiver(app, callback)
@Test fun onReceive() {
val badIntent = fakeIntent("Hello World")
receiver.intentReceiver.onReceive(app, badIntent)
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
.doReturn(MOCK_MODEL_2)
receiver.intentReceiver.onReceive(app, goodIntent)
verify(callback, times(1)).invoke(MOCK_MODEL_2)
}
@Test fun onResume() {
receiver.onResume()
val filterCaptor = argumentCaptor<IntentFilter>()
verify(app).registerReceiver(receiver.intentReceiver, filterCaptor.capture())
val actionIterator = filterCaptor.firstValue.actionsIterator()
assertThat(actionIterator.hasNext()).isTrue()
val filterAction = actionIterator.next()
assertThat(filterAction).isEqualTo(ACTION_STATUS_UPDATE)
assertThat(actionIterator.hasNext()).isFalse()
}
@Test fun onPause() {
receiver.onPause()
verify(app).unregisterReceiver(receiver.intentReceiver)
}
}

View file

@ -0,0 +1,301 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.addsite
//import com.afollestad.nocknock.R.string
//import com.afollestad.nocknock.data.model.Site
//import com.afollestad.nocknock.data.model.SiteSettings
//import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
//import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
//import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
//import com.afollestad.nocknock.engine.validation.ValidationManager
//import com.afollestad.nocknock.mockDatabase
//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 database = mockDatabase()
// private val checkStatusManager = mock<ValidationManager>()
// private val view = mock<AddSiteView>()
//
// private val presenter = RealAddSitePresenter(
// database,
// 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(
// string.validation_mode_status_desc
// )
// }
//
// @Test fun onValidationModeSelected_termSearch() {
// presenter.onValidationModeSelected(1)
// verify(view).showOrHideValidationSearchTerm(true)
// verify(view).showOrHideScriptInput(false)
// verify(view).setValidationModeDescription(
// string.validation_mode_term_desc
// )
// }
//
// @Test fun onValidationModeSelected_javaScript() {
// presenter.onValidationModeSelected(2)
// verify(view).showOrHideValidationSearchTerm(false)
// verify(view).showOrHideScriptInput(true)
// verify(view).setValidationModeDescription(
// 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,
// 60000
// )
//
// 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(string.please_enter_name)
// }
//
// @Test fun commit_urlEmptyError() {
// presenter.commit(
// "Testing",
// "",
// 1,
// STATUS_CODE,
// null,
// 60000
// )
//
// 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(string.please_enter_url)
// }
//
// @Test fun commit_urlFormatError() {
// presenter.commit(
// "Testing",
// "ftp://hello.com",
// 1,
// STATUS_CODE,
// null,
// 60000
// )
//
// 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(string.please_enter_valid_url)
// }
//
// @Test fun commit_checkIntervalError() {
// presenter.commit(
// "Testing",
// "https://hello.com",
// -1,
// STATUS_CODE,
// null,
// 60000
// )
//
// 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(
// string.please_enter_check_interval
// )
// }
//
// @Test fun commit_termSearchError() {
// presenter.commit(
// "Testing",
// "https://hello.com",
// 1,
// TERM_SEARCH,
// null,
// 60000
// )
//
// 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(
// string.please_enter_search_term
// )
// }
//
// @Test fun commit_networkTimeout_error() {
// presenter.commit(
// "Testing",
// "https://hello.com",
// 1,
// STATUS_CODE,
// null,
// 0
// )
//
// val inputErrorsCaptor = argumentCaptor<InputErrors>()
// verify(view).setInputErrors(inputErrorsCaptor.capture())
// verify(checkStatusManager, never())
// .scheduleCheck(any(), any(), any(), any())
//
// val errors = inputErrorsCaptor.firstValue
// assertThat(errors.networkTimeout).isEqualTo(
// string.please_enter_networkTimeout
// )
// }
//
// @Test fun commit_javaScript_error() {
// presenter.commit(
// "Testing",
// "https://hello.com",
// 1,
// JAVASCRIPT,
// null,
// 60000
// )
//
// 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(
// string.please_enter_javaScript
// )
// }
//
// @Test fun commit_success() = runBlocking {
// presenter.commit(
// "Testing",
// "https://hello.com",
// 1,
// STATUS_CODE,
// null,
// 60000
// )
//
// val siteCaptor = argumentCaptor<Site>()
// val settingsCaptor = argumentCaptor<SiteSettings>()
//
// verify(view).setLoading()
// verify(database.siteDao()).insert(siteCaptor.capture())
// verify(database.siteSettingsDao()).insert(settingsCaptor.capture())
// verify(database.validationResultsDao(), never()).insert(any())
//
// val settings = settingsCaptor.firstValue
// val model = siteCaptor.firstValue.copy(
// id = 1, // fill it in because our insert captor doesn't catch this
// settings = settings,
// lastResult = null
// )
//
// verify(view, never()).setInputErrors(any())
// verify(checkStatusManager).scheduleCheck(
// site = model,
// rightNow = true,
// cancelPrevious = true,
// fromFinishingJob = false
// )
//
// verify(view).setDoneLoading()
// verify(view).onSiteAdded()
// }
//}

View file

@ -0,0 +1,158 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.main
import com.afollestad.nocknock.ALL_MOCK_MODELS
import com.afollestad.nocknock.MOCK_MODEL_1
import com.afollestad.nocknock.MOCK_MODEL_2
import com.afollestad.nocknock.MOCK_MODEL_3
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.test
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Test
class MainViewModelTest {
private val database = mockDatabase()
private val notificationManager = mock<NockNotificationManager>()
private val validationManager = mock<ValidationManager>()
private val viewModel = MainViewModel(
database,
notificationManager,
validationManager,
Dispatchers.Default
).apply {
this.mainDispatcher = Dispatchers.Default
}
@After fun tearDown() = viewModel.destroy()
@Test fun onResume() = runBlocking {
val isLoading = viewModel.onIsLoading()
.test()
val sites = viewModel.onSites()
.test()
viewModel.onResume()
verify(notificationManager).cancelStatusNotifications()
verify(validationManager).ensureScheduledChecks()
sites.assertValues(
listOf(),
ALL_MOCK_MODELS
)
isLoading.assertValues(true, false)
}
@Test fun postSiteUpdate_notFound() {
val sites = viewModel.onSites()
.test()
viewModel.postSiteUpdate(MOCK_MODEL_1)
sites.assertNoValues()
}
@Test fun postSiteUpdate() {
val sites = viewModel.onSites()
.test()
viewModel.onResume()
sites.assertValues(
listOf(),
ALL_MOCK_MODELS
)
val updatedModel2 = MOCK_MODEL_2.copy(
name = "Wakanda Forever!!!"
)
val updatedSites = ALL_MOCK_MODELS.toMutableList()
.apply {
this[1] = updatedModel2
}
viewModel.postSiteUpdate(updatedModel2)
sites.assertValues(updatedSites)
}
@Test fun refreshSite() {
viewModel.refreshSite(MOCK_MODEL_3)
verify(validationManager).scheduleCheck(
site = MOCK_MODEL_3,
rightNow = true,
cancelPrevious = true
)
}
@Test fun removeSite_notFound() {
val sites = viewModel.onSites()
.test()
val isLoading = viewModel.onIsLoading()
.test()
viewModel.onResume()
sites.assertValues(
listOf(),
ALL_MOCK_MODELS
)
val modifiedModel = MOCK_MODEL_1.copy(id = 11111)
viewModel.removeSite(modifiedModel)
sites.assertNoValues()
isLoading.assertValues(true, false)
verify(validationManager).cancelCheck(modifiedModel)
verify(notificationManager).cancelStatusNotification(modifiedModel)
verify(database.siteDao()).delete(modifiedModel)
verify(database.siteSettingsDao()).delete(modifiedModel.settings!!)
}
@Test fun removeSite() {
val sites = viewModel.onSites()
.test()
val isLoading = viewModel.onIsLoading()
.test()
viewModel.onResume()
sites.assertValues(
listOf(),
ALL_MOCK_MODELS
)
val modelsWithout1 = ALL_MOCK_MODELS.toMutableList()
.apply {
removeAt(0)
}
viewModel.removeSite(MOCK_MODEL_1)
sites.assertValues(modelsWithout1)
isLoading.assertValues(true, false)
verify(validationManager).cancelCheck(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
verify(database.siteDao()).delete(MOCK_MODEL_1)
verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
}
}

View file

@ -0,0 +1,20 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.viewsite
class ViewSitePresenterTest {
}

View file

@ -1,105 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("DEPRECATION")
package com.afollestad.nocknock.data.legacy
import android.app.Application
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status.CHECKING
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationResult
import javax.inject.Inject
/**
* Migrates from manual SQLite management to Room.
*
* @author Aidan Follestad (@afollestad)
*/
class DbMigrator @Inject constructor(
app: Application,
private val appDb: AppDatabase
) {
private val legacyStore = ServerModelStore(app)
fun migrateAll(): Int {
val legacyModels = legacyStore.get()
var count = 0
for (oldModel in legacyModels) {
// Insert site
val site = oldModel.toNewModel()
val siteId = appDb.siteDao()
.insert(site)
// Insert site settings
val settingsWithId = site.settings!!.copy(
siteId = siteId
)
appDb.siteSettingsDao()
.insert(settingsWithId)
// Insert validation result
site.lastResult?.let {
val resultWithId = it.copy(
siteId = siteId
)
appDb.validationResultsDao()
.insert(resultWithId)
}
count++
}
legacyStore.wipe()
return count
}
private fun ServerModel.toNewModel(): Site {
return Site(
id = 0,
name = this.name,
url = this.url,
settings = this.toSettingsModel(),
lastResult = this.toValidationModel()
)
}
private fun ServerModel.toSettingsModel(): SiteSettings {
return SiteSettings(
siteId = 0,
validationIntervalMs = this.checkInterval,
validationMode = this.validationMode,
validationArgs = this.validationContent,
disabled = this.disabled,
networkTimeout = this.networkTimeout
)
}
private fun ServerModel.toValidationModel(): ValidationResult? {
if (this.lastCheck == LAST_CHECK_NONE) {
return null
}
return ValidationResult(
siteId = 0,
timestampMs = this.lastCheck,
status = if (this.status == CHECKING) WAITING else this.status,
reason = this.reason
)
}
}

View file

@ -1,136 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("DEPRECATION")
package com.afollestad.nocknock.data.legacy
import android.content.ContentValues
import android.database.Cursor
import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.toSiteStatus
import com.afollestad.nocknock.data.model.toValidationMode
const val CHECK_INTERVAL_UNSET = -1L
const val LAST_CHECK_NONE = -1L
/** @author Aidan Follestad (@afollestad)*/
@Deprecated("Deprecated in favor of Site.")
data class ServerModel(
var id: Int = 0,
val name: String,
val url: String,
val status: Status = OK,
val checkInterval: Long = CHECK_INTERVAL_UNSET,
val lastCheck: Long = LAST_CHECK_NONE,
val reason: String? = null,
val validationMode: ValidationMode,
val validationContent: String? = null,
val disabled: Boolean = false,
val networkTimeout: Int = 0
) {
companion object {
const val TABLE_NAME = "server_models"
const val COLUMN_ID = "_id"
const val COLUMN_NAME = "name"
const val COLUMN_URL = "url"
const val COLUMN_STATUS = "status"
const val COLUMN_CHECK_INTERVAL = "check_interval"
const val COLUMN_LAST_CHECK = "last_check"
const val COLUMN_REASON = "reason"
const val COLUMN_VALIDATION_MODE = "validation_mode"
const val COLUMN_VALIDATION_CONTENT = "validation_content"
const val COLUMN_DISABLED = "disabled"
const val COLUMN_NETWORK_TIMEOUT = "network_timeout"
const val DEFAULT_SORT_ORDER = "$COLUMN_NAME ASC, $COLUMN_DISABLED DESC"
fun pull(cursor: Cursor): ServerModel {
return ServerModel(
id = cursor.getInt(
cursor.getColumnIndex(
COLUMN_ID
)
),
name = cursor.getString(
cursor.getColumnIndex(
COLUMN_NAME
)
),
url = cursor.getString(
cursor.getColumnIndex(
COLUMN_URL
)
),
status = cursor.getInt(
cursor.getColumnIndex(
COLUMN_STATUS
)
).toSiteStatus(),
checkInterval = cursor.getLong(
cursor.getColumnIndex(
COLUMN_CHECK_INTERVAL
)
),
lastCheck = cursor.getLong(
cursor.getColumnIndex(
COLUMN_LAST_CHECK
)
),
reason = cursor.getString(
cursor.getColumnIndex(
COLUMN_REASON
)
),
validationMode = cursor.getInt(
cursor.getColumnIndex(
COLUMN_VALIDATION_MODE
)
).toValidationMode(),
validationContent = cursor.getString(
cursor.getColumnIndex(
COLUMN_VALIDATION_CONTENT
)
),
disabled = cursor.getInt(
cursor.getColumnIndex(
COLUMN_DISABLED
)
) == 1,
networkTimeout = cursor.getInt(
cursor.getColumnIndex(
COLUMN_NETWORK_TIMEOUT
)
)
)
}
}
fun toContentValues() = ContentValues().apply {
put(COLUMN_NAME, name)
put(COLUMN_URL, url)
put(COLUMN_STATUS, status.value)
put(COLUMN_CHECK_INTERVAL, checkInterval)
put(COLUMN_LAST_CHECK, lastCheck)
put(COLUMN_REASON, reason)
put(COLUMN_VALIDATION_MODE, validationMode.value)
put(COLUMN_VALIDATION_CONTENT, validationContent)
put(COLUMN_DISABLED, disabled)
put(COLUMN_NETWORK_TIMEOUT, networkTimeout)
}
}

View file

@ -1,78 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("DEPRECATION")
package com.afollestad.nocknock.data.legacy
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
private const val SQL_CREATE_ENTRIES =
"CREATE TABLE ${ServerModel.TABLE_NAME} (" +
"${ServerModel.COLUMN_ID} INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," +
"${ServerModel.COLUMN_NAME} TEXT," +
"${ServerModel.COLUMN_URL} TEXT," +
"${ServerModel.COLUMN_STATUS} INTEGER," +
"${ServerModel.COLUMN_CHECK_INTERVAL} INTEGER," +
"${ServerModel.COLUMN_LAST_CHECK} INTEGER," +
"${ServerModel.COLUMN_REASON} TEXT," +
"${ServerModel.COLUMN_VALIDATION_MODE} INTEGER," +
"${ServerModel.COLUMN_VALIDATION_CONTENT} TEXT," +
"${ServerModel.COLUMN_DISABLED} INTEGER," +
"${ServerModel.COLUMN_NETWORK_TIMEOUT} INTEGER" +
")"
private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${ServerModel.TABLE_NAME}"
/** @author Aidan Follestad (@afollestad) */
@Deprecated("Use AppDatabase.")
internal class ServerModelDbHelper(context: Context) : SQLiteOpenHelper(
context, DATABASE_NAME, null,
DATABASE_VERSION
) {
companion object {
const val DATABASE_VERSION = 3
const val DATABASE_NAME = "ServerModels.db"
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(SQL_CREATE_ENTRIES)
}
override fun onUpgrade(
db: SQLiteDatabase,
oldVersion: Int,
newVersion: Int
) {
if (oldVersion < 3) {
db.execSQL(
"ALTER TABLE ${ServerModel.TABLE_NAME} " +
"ADD COLUMN ${ServerModel.COLUMN_NETWORK_TIMEOUT} INTEGER DEFAULT 10000"
)
}
}
override fun onDowngrade(
db: SQLiteDatabase,
oldVersion: Int,
newVersion: Int
) = onUpgrade(db, oldVersion, newVersion)
fun wipe() {
this.writableDatabase.execSQL(SQL_DELETE_ENTRIES)
}
}

View file

@ -1,163 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("DEPRECATION")
package com.afollestad.nocknock.data.legacy
import android.app.Application
import android.content.ContentValues
import android.database.Cursor
import com.afollestad.nocknock.data.legacy.ServerModel.Companion.COLUMN_ID
import com.afollestad.nocknock.data.legacy.ServerModel.Companion.DEFAULT_SORT_ORDER
import com.afollestad.nocknock.data.legacy.ServerModel.Companion.TABLE_NAME
/** @author Aidan Follestad (@afollestad) */
@Deprecated("Deprecated in favor of AppDatabase.")
internal class ServerModelStore(app: Application) {
private val dbHelper = ServerModelDbHelper(app)
fun get(id: Int? = null): List<ServerModel> {
if (id == null) {
return getAll()
}
val reader = dbHelper.readableDatabase
val selection = "$COLUMN_ID = ?"
val selectionArgs = arrayOf("$id")
val cursor = reader.query(
TABLE_NAME,
null,
selection,
selectionArgs,
null,
null,
DEFAULT_SORT_ORDER,
"1"
)
cursor.use {
val results = readModels(it)
check(results.size == 1) { "Should only get one model per ID." }
return results
}
}
private fun getAll(): List<ServerModel> {
val reader = dbHelper.readableDatabase
val cursor = reader.query(
TABLE_NAME,
null,
null,
null,
null,
null,
DEFAULT_SORT_ORDER,
null
)
cursor.use { return readModels(it) }
}
fun put(model: ServerModel): ServerModel {
check(model.id == 0) { "Cannot put a model that already has an ID." }
val writer = dbHelper.writableDatabase
val newId = writer.insert(TABLE_NAME, null, model.toContentValues())
return model.copy(id = newId.toInt())
}
fun update(model: ServerModel): Int {
check(model.id != 0) { "Cannot update a model that does not have an ID." }
val oldModel = get(model.id).single()
val oldValues = oldModel.toContentValues()
val writer = dbHelper.writableDatabase
val newValues = model.toContentValues()
val valuesDiff = oldValues.diffFrom(newValues)
if (valuesDiff.size() == 0) {
return 0
}
val selection = "$COLUMN_ID = ?"
val selectionArgs = arrayOf("${model.id}")
return writer.update(TABLE_NAME, valuesDiff, selection, selectionArgs)
}
fun delete(model: ServerModel) = delete(model.id)
fun delete(id: Int): Int {
check(id != 0) { "Cannot delete a model that doesn't have an ID." }
val selection = "$COLUMN_ID = ?"
val selectionArgs = arrayOf("$id")
return dbHelper.writableDatabase.delete(TABLE_NAME, selection, selectionArgs)
}
fun wipe() = dbHelper.wipe()
private fun readModels(cursor: Cursor): List<ServerModel> {
val results = mutableListOf<ServerModel>()
while (cursor.moveToNext()) {
results.add(ServerModel.pull(cursor))
}
return results
}
/**
* Returns a [ContentValues] instance which contains only values that have changed between
* the receiver (original) and parameter (new) instances.
*/
private fun ContentValues.diffFrom(contentValues: ContentValues): ContentValues {
val diff = ContentValues()
for ((name, oldValue) in this.valueSet()) {
val newValue = contentValues.get(name)
if (newValue != oldValue) {
diff.putAny(name, newValue)
}
}
return diff
}
/**
* Auto casts an [Any] value and uses the appropriate `put` method to store it
* in the [ContentValues] instance.
*/
private fun ContentValues.putAny(
name: String,
value: Any?
) {
if (value == null) {
putNull(name)
return
}
when (value) {
is String -> put(name, value)
is Byte -> put(name, value)
is Short -> put(name, value)
is Int -> put(name, value)
is Long -> put(name, value)
is Float -> put(name, value)
is Double -> put(name, value)
is Boolean -> put(name, value)
is ByteArray -> put(name, value)
else -> throw IllegalArgumentException("ContentValues can't hold $value")
}
}
}

View file

@ -29,6 +29,12 @@ enum class ValidationMode(val value: Int) {
/** The site is running normally if a block of given JavaScript executes successfully. */
JAVASCRIPT(3);
fun toIndex() = when (this) {
STATUS_CODE -> 0
TERM_SEARCH -> 1
JAVASCRIPT -> 2
}
companion object {
fun fromValue(value: Int) = when (value) {
@ -46,9 +52,3 @@ enum class ValidationMode(val value: Int) {
}
}
}
fun Int.toValidationMode() =
ValidationMode.fromValue(this)
fun Int.indexToValidationMode() =
ValidationMode.fromIndex(this)

View file

@ -18,6 +18,7 @@ ext.versions = [
androidx : '1.0.0',
room : '2.0.0',
lifecycle : '2.0.0',
rxBinding : '3.0.0-alpha1',

View file

@ -15,8 +15,8 @@
*/
package com.afollestad.nocknock.engine;
import com.afollestad.nocknock.engine.statuscheck.RealValidationManager;
import com.afollestad.nocknock.engine.statuscheck.ValidationManager;
import com.afollestad.nocknock.engine.validation.RealValidationManager;
import com.afollestad.nocknock.engine.validation.ValidationManager;
import dagger.Binds;
import dagger.Module;
import javax.inject.Singleton;
@ -27,5 +27,5 @@ public abstract class EngineModule {
@Binds
@Singleton
abstract ValidationManager provideCheckStatusManager(RealValidationManager checkStatusManager);
abstract ValidationManager provideValidationManager(RealValidationManager checkStatusManager);
}

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.engine.statuscheck
package com.afollestad.nocknock.engine.validation
import android.content.BroadcastReceiver
import android.content.Context

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.engine.statuscheck
package com.afollestad.nocknock.engine.validation
import android.app.job.JobParameters
import android.app.job.JobService

View file

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.engine.statuscheck
package com.afollestad.nocknock.engine.validation
import android.app.job.JobScheduler
import android.app.job.JobScheduler.RESULT_SUCCESS
@ -23,7 +23,7 @@ import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.ERROR
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.engine.R
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_SITE_ID
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID
import com.afollestad.nocknock.utilities.providers.BundleProvider
import com.afollestad.nocknock.utilities.providers.JobInfoProvider
import com.afollestad.nocknock.utilities.providers.StringProvider

View file

@ -22,8 +22,8 @@ import com.afollestad.nocknock.data.model.Status.ERROR
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.legacy.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_SITE_ID
import com.afollestad.nocknock.engine.statuscheck.RealValidationManager
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID
import com.afollestad.nocknock.engine.validation.RealValidationManager
import com.afollestad.nocknock.utilities.providers.StringProvider
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any

View file

@ -15,23 +15,26 @@
*/
package com.afollestad.nocknock.utilities.ext
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.IntentFilter
import android.text.Editable
import android.text.TextWatcher
import android.widget.EditText
fun Activity.safeRegisterReceiver(
broadcastReceiver: BroadcastReceiver,
filter: IntentFilter
) {
try {
registerReceiver(broadcastReceiver, filter)
} catch (_: Exception) {
}
}
fun EditText.onTextChanged(cb: (String) -> Unit) {
addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = Unit
fun Activity.safeUnregisterReceiver(broadcastReceiver: BroadcastReceiver) {
try {
unregisterReceiver(broadcastReceiver)
} catch (_: Exception) {
}
override fun beforeTextChanged(
s: CharSequence,
start: Int,
count: Int,
after: Int
) = Unit
override fun onTextChanged(
s: CharSequence,
start: Int,
before: Int,
count: Int
) = cb(s.toString().trim())
})
}

View file

@ -19,6 +19,7 @@ dependencies {
implementation project(':data')
implementation 'androidx.appcompat:appcompat:' + versions.androidx
api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle
api 'com.squareup.okhttp3:okhttp:' + versions.okHttp

View file

@ -1,112 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents
import android.content.Context
import android.util.AttributeSet
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import androidx.annotation.CheckResult
import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.WEEK
import com.afollestad.nocknock.viewcomponents.R.array
import com.afollestad.nocknock.viewcomponents.ext.textAsLong
import kotlinx.android.synthetic.main.check_interval_layout.view.input
import kotlinx.android.synthetic.main.check_interval_layout.view.spinner
import kotlin.math.ceil
/** @author Aidan Follestad (@afollestad) */
class CheckIntervalLayout(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
companion object {
private const val INDEX_MINUTE = 0
private const val INDEX_HOUR = 1
private const val INDEX_DAY = 2
private const val INDEX_WEEK = 3
}
init {
orientation = VERTICAL
inflate(context, R.layout.check_interval_layout, this)
}
override fun onFinishInflate() {
super.onFinishInflate()
val spinnerAdapter = ArrayAdapter(
context,
R.layout.list_item_spinner,
resources.getStringArray(array.interval_options)
)
spinnerAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
spinner.adapter = spinnerAdapter
}
fun setError(error: String?) {
input.error = error
}
fun set(interval: Long) {
when {
interval >= WEEK -> {
input.setText(calculateDisplayValue(interval, WEEK))
spinner.setSelection(3)
}
interval >= DAY -> {
input.setText(calculateDisplayValue(interval, DAY))
spinner.setSelection(2)
}
interval >= HOUR -> {
input.setText(calculateDisplayValue(interval, HOUR))
spinner.setSelection(1)
}
interval >= MINUTE -> {
input.setText(calculateDisplayValue(interval, MINUTE))
spinner.setSelection(0)
}
else -> {
input.setText("0")
spinner.setSelection(0)
}
}
}
@CheckResult fun getSelectedCheckInterval(): Long {
val intervalInput = input.textAsLong()
val spinnerPos = spinner.selectedItemPosition
return when (spinnerPos) {
INDEX_MINUTE -> intervalInput * MINUTE
INDEX_HOUR -> intervalInput * HOUR
INDEX_DAY -> intervalInput * DAY
INDEX_WEEK -> intervalInput * WEEK
else -> throw IllegalStateException("Unexpected index: $spinnerPos")
}
}
private fun calculateDisplayValue(
interval: Long,
by: Long
): String {
val intervalFloat = interval.toFloat()
val byFloat = by.toFloat()
return ceil(intervalFloat / byFloat).toInt()
.toString()
}
}

View file

@ -1,66 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents
import android.content.Context
import android.util.AttributeSet
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) */
class JavaScriptInputLayout(
context: Context,
attrs: AttributeSet? = null
) : HorizontalScrollView(context, attrs) {
init {
val contentInset = dimenInt(R.dimen.content_inset)
val contentInsetHalf = dimenInt(R.dimen.content_inset_half)
setPadding(
contentInsetHalf, // left
contentInset, // top
contentInsetHalf, // right
contentInset // bottom
)
elevation = dimenFloat(R.dimen.default_elevation)
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()
return
}
userInput.setText(code.trim())
}
@CheckResult fun getCode() = userInput.trimmedText()
fun clear() = userInput.setText("")
private fun setDefaultCode() = userInput.setText(R.string.default_js)
}

View file

@ -20,6 +20,9 @@ import android.os.Handler
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import com.afollestad.nocknock.viewcomponents.ext.hide
import com.afollestad.nocknock.viewcomponents.ext.show
@ -43,12 +46,19 @@ class LoadingIndicatorFrame(
isFocusable = true
}
fun setLoading() {
delayHandler.postDelayed(showRunnable, SHOW_DELAY_MS)
fun setIsLoading(isLoading: Boolean) {
delayHandler.removeCallbacks(showRunnable)
if (isLoading) {
delayHandler.postDelayed(showRunnable, SHOW_DELAY_MS)
} else {
hide()
}
}
fun setDone() {
delayHandler.removeCallbacks(showRunnable)
hide()
}
fun observe(
owner: LifecycleOwner,
data: LiveData<Boolean>
) = data.observe(owner, Observer {
setIsLoading(it)
})
}

View file

@ -0,0 +1,20 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.ext
fun Int?.isNullOrLessThan(than: Int): Boolean {
return this == null || this < than
}

View file

@ -0,0 +1,148 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.ext
import android.view.View
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.Transformations
import com.afollestad.nocknock.utilities.ext.onTextChanged
fun <X, Y> LiveData<X>.map(mapper: (X) -> Y) =
Transformations.map(this, mapper)!!
fun <X, Y> LiveData<X>.switchMap(mapper: (X) -> LiveData<Y>) =
Transformations.switchMap(this, mapper)!!
inline fun <reified T> EditText.attachLiveData(
lifecycleOwner: LifecycleOwner,
data: MutableLiveData<T>
) {
// Out
when {
T::class == Int::class -> {
onTextChanged { data.postValue(it.trim().toInt() as T) }
}
T::class == Long::class -> {
onTextChanged { data.postValue(it.trim().toLong() as T) }
}
T::class == String::class -> {
onTextChanged { data.postValue(it.trim() as T) }
}
else -> {
throw IllegalArgumentException("Can't send EditText text changes into ${T::class}")
}
}
// In
data.observe(lifecycleOwner, Observer {
when {
T::class == Int::class -> setText(it as Int)
T::class == String::class -> setText(it as String)
}
})
}
fun <T> Spinner.attachLiveData(
lifecycleOwner: LifecycleOwner,
data: MutableLiveData<T>,
outTransformer: (Int) -> T,
inTransformer: (T) -> Int
) {
// Out
onItemSelected { data.postValue(outTransformer(it)) }
// In
data.observe(lifecycleOwner, Observer {
setSelection(inTransformer(it))
})
}
fun LiveData<Int?>.toViewError(
owner: LifecycleOwner,
view: EditText
) = observe(owner, Observer { error ->
view.error = if (error != null) {
view.resources.getString(error)
} else {
null
}
})
inline fun <reified T> LiveData<T>.toViewText(
owner: LifecycleOwner,
view: TextView
) = observe(owner, Observer {
when {
T::class == Int::class -> view.setText(it as Int)
T::class == String::class -> view.text = it as String
else -> throw IllegalStateException("Cannot set ${T::class} as view text.")
}
})
fun LiveData<Boolean>.toViewVisibility(
owner: LifecycleOwner,
view: View
) = observe(owner, Observer { view.showOrHide(it) })
/** @author Aidan Follestad (@afollestad) */
class ZipLiveData<T, K>(
source1: LiveData<T>,
source2: LiveData<K>
) : MediatorLiveData<Pair<T, K>>() {
private var data1: T? = null
private var data2: K? = null
init {
super.addSource(source1) {
data1 = it
maybeNotify()
}
super.addSource(source2) {
data2 = it
maybeNotify()
}
}
private fun maybeNotify() {
if (data1 != null && data2 != null) {
value = Pair(data1!!, data2!!)
}
}
override fun <S : Any?> addSource(
source: LiveData<S>,
onChanged: Observer<in S>
) {
throw UnsupportedOperationException()
}
override fun <T : Any?> removeSource(toRemote: LiveData<T>) {
throw UnsupportedOperationException()
}
}
fun <T, K> zip(
source1: LiveData<T>,
source2: LiveData<K>
): MediatorLiveData<Pair<T, K>> {
return ZipLiveData(source1, source2)
}

View file

@ -1,72 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.ext
import android.widget.TextView
fun TextView.trimmedText() = text.toString().trim()
fun TextView.textAsInt(defaultValue: Int = 0): Int {
val text = trimmedText()
return if (text.isEmpty()) defaultValue else text.toInt()
}
fun TextView.textAsLong(defaultValue: Long = 0L): Long {
val text = trimmedText()
return if (text.isEmpty()) defaultValue else text.toLong()
}
///** @author https://stackoverflow.com/a/53296137/309644 */
//fun EditText.addFilter(filter: InputFilter) {
// filters =
// if (filters.isNullOrEmpty()) {
// arrayOf(filter)
// } else {
// filters
// .toMutableList()
// .apply {
// removeAll { it.javaClass == filter.javaClass }
// add(filter)
// }
// .toTypedArray()
// }
//}
//
//fun <T : InputFilter> EditText.removeFilters(type: Class<in T>) {
// filters =
// if (filters.isNullOrEmpty()) {
// filters
// } else {
// filters
// .toMutableList()
// .apply {
// removeAll { it.javaClass == type }
// }
// .toTypedArray()
// }
//}
//
//fun EditText.setMaxLength(maxLength: Int) {
// if (maxLength <= 0) {
// removeFilters(LengthFilter::class.java)
// } else {
// if (text.length > maxLength) {
// setText(text.subSequence(0, maxLength))
// setSelection(text.length)
// }
// addFilter(LengthFilter(maxLength))
// }
//}

View file

@ -0,0 +1,133 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.interval
import android.content.Context
import android.util.AttributeSet
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import androidx.lifecycle.Lifecycle.State.DESTROYED
import androidx.lifecycle.Lifecycle.State.RESUMED
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.WEEK
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.viewcomponents.R.array
import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import kotlinx.android.synthetic.main.check_interval_layout.view.input
import kotlinx.android.synthetic.main.check_interval_layout.view.spinner
/** @author Aidan Follestad (@afollestad) */
class CheckIntervalLayout(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs), LifecycleOwner {
companion object {
const val INDEX_MINUTE = 0
const val INDEX_HOUR = 1
const val INDEX_DAY = 2
const val INDEX_WEEK = 3
}
init {
orientation = VERTICAL
inflate(context, layout.check_interval_layout, this)
}
private lateinit var valueData: MutableLiveData<Int>
private lateinit var multiplierData: MutableLiveData<Long>
private val lifecycle = LifecycleRegistry(this)
override fun onFinishInflate() {
super.onFinishInflate()
val spinnerAdapter = ArrayAdapter(
context,
layout.list_item_spinner,
resources.getStringArray(array.interval_options)
)
spinnerAdapter.setDropDownViewResource(
layout.list_item_spinner_dropdown
)
spinner.adapter = spinnerAdapter
lifecycle.markState(STARTED)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
lifecycle.markState(RESUMED)
}
override fun onDetachedFromWindow() {
lifecycle.markState(DESTROYED)
super.onDetachedFromWindow()
}
fun setError(error: String?) {
input.error = error
}
fun attach(
valueData: MutableLiveData<Int>,
multiplierData: MutableLiveData<Long>,
errorData: LiveData<Int?>
) {
this.valueData = valueData
this.multiplierData = multiplierData
this.valueData.observe(this, Observer {
input.setText("$it")
})
this.multiplierData.observe(this, Observer { multiplier ->
val targetPos = when (multiplier) {
MINUTE -> 0
HOUR -> 1
DAY -> 2
WEEK -> 3
else -> throw IllegalStateException("Unknown multiplier: $multiplier")
}
if (spinner.selectedItemPosition != targetPos) {
spinner.setSelection(targetPos)
}
})
errorData.observe(this, Observer {
setError(if (it != null) resources.getString(it) else null)
})
input.onTextChanged { this.valueData.value = it.toInt() }
spinner.onItemSelected {
this.multiplierData.value = when (it) {
INDEX_MINUTE -> MINUTE
INDEX_HOUR -> HOUR
INDEX_DAY -> DAY
INDEX_WEEK -> WEEK
else -> throw IllegalStateException("Unknown multiplier index: $it")
}
}
}
override fun getLifecycle() = lifecycle
}

View file

@ -0,0 +1,111 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.js
import android.content.Context
import android.util.AttributeSet
import android.widget.HorizontalScrollView
import androidx.lifecycle.Lifecycle.State.CREATED
import androidx.lifecycle.Lifecycle.State.DESTROYED
import androidx.lifecycle.Lifecycle.State.RESUMED
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.viewcomponents.R.dimen
import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.R.string
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.toViewVisibility
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) */
class JavaScriptInputLayout(
context: Context,
attrs: AttributeSet? = null
) : HorizontalScrollView(context, attrs), LifecycleOwner {
private val lifecycle = LifecycleRegistry(this)
private lateinit var codeData: MutableLiveData<String>
init {
val contentInset = dimenInt(dimen.content_inset)
val contentInsetHalf = dimenInt(
dimen.content_inset_half
)
setPadding(
contentInsetHalf, // left
contentInset, // top
contentInsetHalf, // right
contentInset // bottom
)
elevation = dimenFloat(dimen.default_elevation)
inflate(context, layout.javascript_input_layout, this)
}
override fun onFinishInflate() {
super.onFinishInflate()
lifecycle.markState(CREATED)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
lifecycle.markState(RESUMED)
}
override fun onDetachedFromWindow() {
lifecycle.markState(DESTROYED)
super.onDetachedFromWindow()
}
fun setError(error: String?) {
error_text.showOrHide(error != null)
error_text.text = error
}
fun attach(
codeData: MutableLiveData<String>,
errorData: LiveData<Int?>,
visibility: LiveData<Boolean>
) {
this.codeData = codeData
this.codeData.observe(this, Observer {
if (it.isNullOrEmpty()) {
setDefaultCode()
} else {
userInput.setText(it)
}
})
errorData.observe(this, Observer {
setError(if (it != null) resources.getString(it) else null)
})
visibility.toViewVisibility(this, this)
userInput.onTextChanged { this.codeData.value = it }
}
fun clear() = userInput.setText("")
private fun setDefaultCode() = userInput.setText(
string.default_js
)
override fun getLifecycle() = lifecycle
}