diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 17bc1fa..0000000 --- a/.editorconfig +++ /dev/null @@ -1,3 +0,0 @@ -[*.kt] -indent_size = 2 -continuation_indent_size=4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 161128f..454e51a 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,6 @@ gradle-app.setting .gradletasknamecache # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties \ No newline at end of file +# gradle/wrapper/gradle-wrapper.properties + +app/google-services.json \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 9d1e235..50f0406 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -40,7 +40,7 @@ - + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 771dddf..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: android -jdk: oraclejdk8 - -env: - matrix: - - ANDROID_TARGET=android-21 ANDROID_ABI=armeabi-v7a - -android: - components: - - tools # to get the new `repository-11.xml` - - tools # see https://github.com/travis-ci/travis-ci/issues/6040#issuecomment-219367943) - - platform-tools - - build-tools-28.0.3 - - android-28 - - licenses: - - '.+' - -before_install: - - yes | sdkmanager "platforms;android-28" -script: - - ./gradlew build connectedCheck diff --git a/README.md b/README.md index 156123f..84ee16c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ ## Nock Nock -[![Build Status](https://travis-ci.org/afollestad/nock-nock.svg)](https://travis-ci.org/afollestad/nock-nock) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html) ![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png) diff --git a/app/build.gradle b/app/build.gradle index 6b0bbbe..d6b315e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,18 +4,6 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-android-extensions' -apply plugin: 'io.fabric' - -def getFabricApiKey() { - def propsFile = project.rootProject.file('local.properties') - if (!propsFile.exists()) { - return "" - } - Properties properties = new Properties() - properties.load(propsFile.newDataInputStream()) - return properties.getProperty("fabric.apikey") ?: "" -} - android { compileSdkVersion versions.compileSdk buildToolsVersion versions.buildTools @@ -26,17 +14,15 @@ android { targetSdkVersion versions.compileSdk versionCode versions.publishVersionCode versionName versions.publishVersion - manifestPlaceholders = [fabricKey:getFabricApiKey()] } - buildTypes { - debug { - ext.enableCrashlytics = false - buildConfigField "String", "FABRIC_API_KEY", "\"\"" - } - release { - buildConfigField "String", "FABRIC_API_KEY", "\"${getFabricApiKey()}\"" - } + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } + + packagingOptions { + exclude 'META-INF/atomicfu.kotlin_module' } } @@ -52,6 +38,7 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView implementation 'com.google.android.material:material:' + versions.googleMaterial implementation 'androidx.browser:browser:' + versions.androidxBrowser + implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore // Lifecycle kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle @@ -85,4 +72,8 @@ dependencies { androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner } -apply from: '../spotless.gradle' \ No newline at end of file +apply from: '../spotless.gradle' +apply from: '../mock/mock.gradle' + +apply plugin: "io.fabric" +apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt index 2cd0af3..3c29301 100644 --- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt +++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt @@ -47,10 +47,8 @@ class NockNockApp : Application() { Timber.plant(DebugTree()) } - if (BuildConfig.FABRIC_API_KEY.isNotEmpty()) { - Timber.plant(FabricTree()) - Fabric.with(this, Crashlytics()) - } + Timber.plant(FabricTree()) + Fabric.with(this, Crashlytics()) val modules = listOf( prefModule, diff --git a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt index c43b729..3220567 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt @@ -15,10 +15,15 @@ */ package com.afollestad.nocknock.ui +import android.content.res.Configuration +import android.os.Build import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.afollestad.nocknock.R import com.afollestad.nocknock.koin.PREF_DARK_MODE +import com.afollestad.nocknock.ui.NightMode.DISABLED +import com.afollestad.nocknock.ui.NightMode.ENABLED +import com.afollestad.nocknock.ui.NightMode.UNKNOWN import com.afollestad.nocknock.utilities.rx.attachLifecycle import com.afollestad.rxkprefs.Pref import org.koin.android.ext.android.inject @@ -35,16 +40,35 @@ abstract class DarkModeSwitchActivity : AppCompatActivity() { setTheme(themeRes()) super.onCreate(savedInstanceState) - darkModePref.observe() - .filter { it != isDarkModeEnabled } - .subscribe { - log("Theme changed, recreating Activity.") - recreate() - } - .attachLifecycle(this) + if (getCurrentNightMode() == UNKNOWN) { + darkModePref.observe() + .filter { it != isDarkModeEnabled } + .subscribe { + log("Theme changed, recreating Activity.") + recreate() + } + .attachLifecycle(this) + } } - protected fun isDarkMode() = darkModePref.get() + protected fun getCurrentNightMode(): NightMode { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return UNKNOWN + } + return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> return ENABLED + Configuration.UI_MODE_NIGHT_NO -> return DISABLED + else -> UNKNOWN + } + } + + protected fun isDarkMode(): Boolean { + return when (getCurrentNightMode()) { + ENABLED -> true + DISABLED -> false + else -> darkModePref.get() + } + } protected fun toggleDarkMode() = setDarkMode(!isDarkMode()) diff --git a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt new file mode 100644 index 0000000..2930fea --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt @@ -0,0 +1,26 @@ +/** + * 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 + +/** @author Aidan Follestad (@afollestad) */ +enum class NightMode { + /** Night mode is on at the system level. */ + ENABLED, + /** Night mode is off at the system level. */ + DISABLED, + /** We don't know about night mode, fallback to custom impl. */ + UNKNOWN +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt index 26a21f9..e15a29f 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt @@ -31,11 +31,13 @@ import com.afollestad.nocknock.utilities.ext.onTextChanged import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection import com.afollestad.nocknock.utilities.livedata.distinct import com.afollestad.nocknock.viewcomponents.ext.dimenFloat +import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition import com.afollestad.nocknock.viewcomponents.ext.onScroll import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData -import com.afollestad.nocknock.viewcomponents.livedata.toViewError import com.afollestad.nocknock.viewcomponents.livedata.toViewText import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility +import com.afollestad.vvalidator.form +import com.afollestad.vvalidator.form.Form import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout import kotlinx.android.synthetic.main.activity_addsite.headersLayout import kotlinx.android.synthetic.main.activity_addsite.inputName @@ -64,12 +66,14 @@ class AddSiteActivity : DarkModeSwitchActivity() { } private val viewModel by viewModel() + private lateinit var validationForm: Form @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_addsite) setupUi() + setupValidation() lifecycle.addObserver(viewModel) @@ -82,23 +86,17 @@ class AddSiteActivity : DarkModeSwitchActivity() { // Name inputName.attachLiveData(this, viewModel.name) - viewModel.onNameError() - .toViewError(this, inputName) // Tags inputTags.attachLiveData(this, viewModel.tags) // 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( @@ -107,8 +105,6 @@ class AddSiteActivity : DarkModeSwitchActivity() { outTransformer = { ValidationMode.fromIndex(it) }, inTransformer = { it.toIndex() } ) - viewModel.onValidationSearchTermError() - .toViewError(this, responseValidationSearchTerm) viewModel.onValidationModeDescription() .toViewText(this, validationModeDescription) @@ -121,32 +117,10 @@ class AddSiteActivity : DarkModeSwitchActivity() { 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() - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes - ) - // SSL certificate sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } viewModel.certificateUri.distinct() .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - viewModel.onCertificateError() - .toViewError(this, sslCertificateInput) // Headers headersLayout.attach(viewModel.headers) @@ -156,15 +130,6 @@ class AddSiteActivity : DarkModeSwitchActivity() { toolbarTitle.setText(R.string.add_site) toolbar.run { inflateMenu(R.menu.menu_addsite) - setOnMenuItemClickListener { - if (it.itemId == R.id.commit) { - viewModel.commit { - setResult(RESULT_OK) - finish() - } - } - true - } setNavigationIcon(R.drawable.ic_action_close) setNavigationOnClickListener { finish() } } @@ -195,6 +160,59 @@ class AddSiteActivity : DarkModeSwitchActivity() { } } + private fun setupValidation() { + validationForm = form { + input(inputName, name = "Name") { + isNotEmpty().description(R.string.please_enter_name) + } + input(inputUrl, name = "URL") { + isNotEmpty().description(R.string.please_enter_url) + isUrl().description(R.string.please_enter_valid_url) + } + input(responseTimeoutInput, name = "Timeout", optional = true) { + isNumber().greaterThan(0) + .description(R.string.please_enter_networkTimeout) + } + input(responseValidationSearchTerm, name = "Search term") { + conditional(responseValidationSearchTerm.isVisibleCondition()) { + isNotEmpty().description(R.string.please_enter_search_term) + } + } + input(sslCertificateInput, name = "Certificate Path", optional = true) { + isUri().hasScheme("file", "content") + .that { it.host != null } + .description(R.string.please_enter_validCertUri) + } + submitWith(toolbar.menu, R.id.commit) { + viewModel.commit { + setResult(RESULT_OK) + finish() + } + } + } + + // Validation script + scriptInputLayout.attach( + codeData = viewModel.validationScript, + visibility = viewModel.onValidationScriptVisibility(), + form = validationForm + ) + + // Check interval + checkIntervalLayout.attach( + valueData = viewModel.checkIntervalValue, + multiplierData = viewModel.checkIntervalUnit, + form = validationForm + ) + + // Retry Policy + retryPolicyLayout.attach( + timesData = viewModel.retryPolicyTimes, + minutesData = viewModel.retryPolicyMinutes, + form = validationForm + ) + } + override fun onResume() { super.onResume() appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt index 87da65c..d7d8ed5 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt @@ -39,9 +39,7 @@ import com.afollestad.nocknock.data.putSite import com.afollestad.nocknock.engine.validation.ValidationExecutor import com.afollestad.nocknock.ui.ScopedViewModel import com.afollestad.nocknock.utilities.ext.MINUTE -import com.afollestad.nocknock.utilities.ext.toUri import com.afollestad.nocknock.utilities.livedata.map -import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -83,23 +81,10 @@ class AddSiteViewModel( headers.value = emptyList() } - // Private properties private val isLoading = MutableLiveData() - private val nameError = MutableLiveData() - private val urlError = MutableLiveData() - private val timeoutError = MutableLiveData() - private val validationSearchTermError = MutableLiveData() - private val validationScriptError = MutableLiveData() - private val checkIntervalValueError = MutableLiveData() - private val certificateError = MutableLiveData() - // Expose private properties or calculated properties @CheckResult fun onIsLoading(): LiveData = isLoading - @CheckResult fun onNameError(): LiveData = nameError - - @CheckResult fun onUrlError(): LiveData = urlError - @CheckResult fun onUrlWarningVisibility(): LiveData { return url.map { val parsed = HttpUrl.parse(it) @@ -107,8 +92,6 @@ class AddSiteViewModel( } } - @CheckResult fun onTimeoutError(): LiveData = timeoutError - @CheckResult fun onValidationModeDescription(): LiveData { return validationMode.map { when (it!!) { @@ -119,19 +102,9 @@ class AddSiteViewModel( } } - @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError + @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - @CheckResult fun onValidationSearchTermVisibility() = - validationMode.map { it == TERM_SEARCH } - - @CheckResult fun onValidationScriptError(): LiveData = validationScriptError - - @CheckResult fun onValidationScriptVisibility() = - validationMode.map { it == JAVASCRIPT } - - @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError - - @CheckResult fun onCertificateError(): LiveData = certificateError + @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } // Actions fun commit(done: () -> Unit) { @@ -171,89 +144,7 @@ class AddSiteViewModel( } 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 val timeout = timeout.value ?: 10_000 - if (timeout < 0) { - 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.value == TERM_SEARCH && - validationSearchTerm.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = R.string.please_enter_search_term - validationScriptError.value = null - } else if (validationMode.value == JAVASCRIPT && - validationScript.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = null - validationScriptError.value = R.string.please_enter_javaScript - } else { - validationSearchTermError.value = null - validationScriptError.value = null - } - - // Validate SSL certificate - val certString = certificateUri.value - if (certString != null) { - val rawCertUri = certString.toUri() - val certUri = if (rawCertUri.scheme == null) { - rawCertUri.buildUpon() - .scheme("file") - .build() - } else { - rawCertUri - } - if (certUri.scheme != "content" && certUri.scheme != "file") { - errorCount++ - certificateError.value = R.string.please_enter_validCertUri - } else { - certificateError.value = null - } - } - - if (errorCount > 0) { - return null - } - val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" val newSettings = SiteSettings( @@ -262,7 +153,7 @@ class AddSiteViewModel( validationArgs = getValidationArgs(), networkTimeout = timeout, disabled = false, - certificate = certificateUri.value.toString() + certificate = certificateUri.value?.toString() ) val newLastResult = ValidationResult( @@ -275,7 +166,8 @@ class AddSiteViewModel( val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { RetryPolicy( - count = retryPolicyTimes, minutes = retryPolicyMinutes + count = retryPolicyTimes, + minutes = retryPolicyMinutes ) } else { null diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt index bd7796c..aec76d8 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt @@ -24,7 +24,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItems -import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.afollestad.nocknock.R import com.afollestad.nocknock.adapter.SiteAdapter import com.afollestad.nocknock.adapter.TagAdapter @@ -33,10 +32,8 @@ import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.dialogs.AboutDialog import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.ui.DarkModeSwitchActivity +import com.afollestad.nocknock.ui.NightMode.UNKNOWN import com.afollestad.nocknock.utilities.providers.IntentProvider -import com.afollestad.nocknock.utilities.ui.toast -import com.afollestad.nocknock.viewUrl -import com.afollestad.nocknock.viewUrlWithApp import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.list @@ -93,12 +90,17 @@ class MainActivity : DarkModeSwitchActivity() { toolbar.run { inflateMenu(R.menu.menu_main) menu.findItem(R.id.dark_mode) - .isChecked = isDarkMode() + .apply { + if (getCurrentNightMode() == UNKNOWN) { + isChecked = isDarkMode() + } else { + isVisible = false + } + } setOnMenuItemClickListener { item -> when (item.itemId) { R.id.about -> AboutDialog.show(this@MainActivity) R.id.dark_mode -> toggleDarkMode() - R.id.support_me -> supportMe() } return@setOnMenuItemClickListener true } @@ -144,20 +146,4 @@ class MainActivity : DarkModeSwitchActivity() { viewSite(model) } } - - private fun supportMe() { - MaterialDialog(this).show { - title(R.string.support_me) - message(R.string.support_me_message, html = true, lineHeightMultiplier = 1.4f) - listItemsSingleChoice(R.array.donation_options) { _, index, _ -> - when (index) { - 0 -> viewUrl("https://paypal.me/AidanFollestad") - 1 -> viewUrlWithApp("https://cash.me/\$afollestad", pkg = "com.squareup.cash") - 2 -> viewUrlWithApp("https://venmo.com/afollestad", pkg = "com.venmo") - } - toast(R.string.thank_you) - } - positiveButton(R.string.next) - } - } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt index 358dfd1..2aa312c 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt @@ -32,11 +32,13 @@ import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection import com.afollestad.nocknock.utilities.livedata.distinct import com.afollestad.nocknock.utilities.providers.IntentProvider import com.afollestad.nocknock.viewcomponents.ext.dimenFloat +import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition import com.afollestad.nocknock.viewcomponents.ext.onScroll import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData -import com.afollestad.nocknock.viewcomponents.livedata.toViewError import com.afollestad.nocknock.viewcomponents.livedata.toViewText import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility +import com.afollestad.vvalidator.form +import com.afollestad.vvalidator.form.Form import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout import kotlinx.android.synthetic.main.activity_viewsite.headersLayout import kotlinx.android.synthetic.main.activity_viewsite.iconStatus @@ -69,6 +71,7 @@ class ViewSiteActivity : DarkModeSwitchActivity() { } internal val viewModel by viewModel() + private lateinit var validationForm: Form private val intentProvider by inject() private val statusUpdateReceiver by lazy { @@ -81,17 +84,18 @@ class ViewSiteActivity : DarkModeSwitchActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_viewsite) - setupUi() - - lifecycle.run { - addObserver(viewModel) - addObserver(statusUpdateReceiver) - } // Populate view model with initial data val model = intent.getSerializableExtra(KEY_SITE) as Site viewModel.setModel(model) + setupUi() + setupValidation() + lifecycle.run { + addObserver(viewModel) + addObserver(statusUpdateReceiver) + } + // Loading loadingProgress.observe(this, viewModel.onIsLoading()) @@ -103,23 +107,17 @@ class ViewSiteActivity : DarkModeSwitchActivity() { // Name inputName.attachLiveData(this, viewModel.name) - viewModel.onNameError() - .toViewError(this, inputName) // Tags inputTags.attachLiveData(this, viewModel.tags) // 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( @@ -128,8 +126,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() { outTransformer = { ValidationMode.fromIndex(it) }, inTransformer = { it.toIndex() } ) - viewModel.onValidationSearchTermError() - .toViewError(this, responseValidationSearchTerm) viewModel.onValidationModeDescription() .toViewText(this, validationModeDescription) @@ -138,32 +134,10 @@ class ViewSiteActivity : DarkModeSwitchActivity() { 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() - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes - ) - // SSL certificate sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } viewModel.certificateUri.distinct() .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - viewModel.onCertificateError() - .toViewError(this, sslCertificateInput) // Headers headersLayout.attach(viewModel.headers) @@ -190,7 +164,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() { setOnMenuItemClickListener { when (it.itemId) { - R.id.commit -> viewModel.commit { finish() } R.id.remove -> maybeRemoveSite() R.id.disableChecks -> maybeDisableChecks() } @@ -238,6 +211,56 @@ class ViewSiteActivity : DarkModeSwitchActivity() { } } + private fun setupValidation() { + validationForm = form { + input(inputName, name = "Name") { + isNotEmpty().description(R.string.please_enter_name) + } + input(inputUrl, name = "URL") { + isNotEmpty().description(R.string.please_enter_url) + isUrl().description(R.string.please_enter_valid_url) + } + input(responseValidationSearchTerm, name = "Search term") { + conditional(responseValidationSearchTerm.isVisibleCondition()) { + isNotEmpty().description(R.string.please_enter_search_term) + } + } + input(responseTimeoutInput, name = "Timeout", optional = true) { + isNumber().greaterThan(0) + .description(R.string.please_enter_networkTimeout) + } + input(sslCertificateInput, name = "Certificate Path", optional = true) { + isUri().hasScheme("file", "content") + .that { it.host != null } + .description(R.string.please_enter_validCertUri) + } + submitWith(toolbar.menu, R.id.commit) { + viewModel.commit { finish() } + } + } + + // Validation script + scriptInputLayout.attach( + codeData = viewModel.validationScript, + visibility = viewModel.onValidationScriptVisibility(), + form = validationForm + ) + + // Check interval + checkIntervalLayout.attach( + valueData = viewModel.checkIntervalValue, + multiplierData = viewModel.checkIntervalUnit, + form = validationForm + ) + + // Retry Policy + retryPolicyLayout.attach( + timesData = viewModel.retryPolicyTimes, + minutesData = viewModel.retryPolicyMinutes, + form = validationForm + ) + } + override fun onResume() { super.onResume() appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt index 91c3125..b5c9f93 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt @@ -40,11 +40,9 @@ import com.afollestad.nocknock.engine.validation.ValidationExecutor import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.ui.ScopedViewModel import com.afollestad.nocknock.utilities.ext.formatDate -import com.afollestad.nocknock.utilities.ext.toUri import com.afollestad.nocknock.utilities.livedata.map import com.afollestad.nocknock.utilities.livedata.zip import com.afollestad.nocknock.utilities.providers.StringProvider -import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -81,23 +79,10 @@ class ViewSiteViewModel( internal val disabled = MutableLiveData() internal val lastResult = MutableLiveData() - // Private properties private val isLoading = MutableLiveData() - private val nameError = MutableLiveData() - private val urlError = MutableLiveData() - private val timeoutError = MutableLiveData() - private val validationSearchTermError = MutableLiveData() - private val validationScriptError = MutableLiveData() - private val checkIntervalValueError = MutableLiveData() - private val certificateError = MutableLiveData() - // Expose private properties or calculated properties @CheckResult fun onIsLoading(): LiveData = isLoading - @CheckResult fun onNameError(): LiveData = nameError - - @CheckResult fun onUrlError(): LiveData = urlError - @CheckResult fun onUrlWarningVisibility(): LiveData { return url.map { val parsed = HttpUrl.parse(it) @@ -105,8 +90,6 @@ class ViewSiteViewModel( } } - @CheckResult fun onTimeoutError(): LiveData = timeoutError - @CheckResult fun onValidationModeDescription(): LiveData { return validationMode.map { when (it!!) { @@ -117,22 +100,11 @@ class ViewSiteViewModel( } } - @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError + @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - @CheckResult fun onValidationSearchTermVisibility() = - validationMode.map { it == TERM_SEARCH } + @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } - @CheckResult fun onValidationScriptError(): LiveData = validationScriptError - - @CheckResult fun onValidationScriptVisibility() = - validationMode.map { it == JAVASCRIPT } - - @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError - - @CheckResult fun onDisableChecksVisibility(): LiveData = - disabled.map { !it } - - @CheckResult fun onCertificateError(): LiveData = certificateError + @CheckResult fun onDisableChecksVisibility(): LiveData = disabled.map { !it } @CheckResult fun onDoneButtonText(): LiveData = disabled.map { @@ -250,90 +222,8 @@ class ViewSiteViewModel( } 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 val timeout = timeout.value ?: 10_000 - if (timeout < 0) { - 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.value == TERM_SEARCH && - validationSearchTerm.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = R.string.please_enter_search_term - validationScriptError.value = null - } else if (validationMode.value == JAVASCRIPT && - validationScript.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = null - validationScriptError.value = R.string.please_enter_javaScript - } else { - validationSearchTermError.value = null - validationScriptError.value = null - } - - // Validate SSL certificate - val certString = certificateUri.value - if (certString != null) { - val rawCertUri = certString.toUri() - val certUri = if (rawCertUri.scheme == null) { - rawCertUri.buildUpon() - .scheme("file") - .build() - } else { - rawCertUri - } - if (certUri.scheme != "content" && certUri.scheme != "file") { - errorCount++ - certificateError.value = R.string.please_enter_validCertUri - } else { - certificateError.value = null - } - } - - if (errorCount > 0) { - return null - } - - val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" + val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: "" val newSettings = site.settings!!.copy( validationIntervalMs = getCheckIntervalMs(), @@ -341,7 +231,7 @@ class ViewSiteViewModel( validationArgs = getValidationArgs(), networkTimeout = timeout, disabled = false, - certificate = certificateUri.value.toString() + certificate = certificateUri.value?.toString() ) val retryPolicyTimes = retryPolicyTimes.value ?: 0 @@ -349,11 +239,15 @@ class ViewSiteViewModel( val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { if (site.retryPolicy != null) { // Have existing policy, update it - site.retryPolicy!!.copy(count = retryPolicyTimes, minutes = retryPolicyMinutes) + site.retryPolicy!!.copy( + count = retryPolicyTimes, + minutes = retryPolicyMinutes + ) } else { // Create new policy RetryPolicy( - count = retryPolicyTimes, minutes = retryPolicyMinutes + count = retryPolicyTimes, + minutes = retryPolicyMinutes ) } } else { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt index 3864984..800f235 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt @@ -55,7 +55,11 @@ fun ViewSiteViewModel.setModel(site: Site) { setCheckInterval(settings.validationIntervalMs) setRetryPolicy(site.retryPolicy) headers.value = site.headers - certificateUri.value = settings.certificate + if (settings.certificate == "null") { + certificateUri.value = "" + } else { + certificateUri.value = settings.certificate + } this.disabled.value = settings.disabled this.lastResult.value = site.lastResult @@ -65,22 +69,22 @@ private fun ViewSiteViewModel.setCheckInterval(interval: Long) { when { interval >= WEEK -> { checkIntervalValue.value = - getIntervalFromUnit(interval, WEEK) + getIntervalFromUnit(interval, WEEK) checkIntervalUnit.value = WEEK } interval >= DAY -> { checkIntervalValue.value = - getIntervalFromUnit(interval, DAY) + getIntervalFromUnit(interval, DAY) checkIntervalUnit.value = DAY } interval >= HOUR -> { checkIntervalValue.value = - getIntervalFromUnit(interval, HOUR) + getIntervalFromUnit(interval, HOUR) checkIntervalUnit.value = HOUR } interval >= MINUTE -> { checkIntervalValue.value = - getIntervalFromUnit(interval, MINUTE) + getIntervalFromUnit(interval, MINUTE) checkIntervalUnit.value = MINUTE } else -> { diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml index f7bd3e8..80f83da 100644 --- a/app/src/main/res/layout/activity_addsite.xml +++ b/app/src/main/res/layout/activity_addsite.xml @@ -123,7 +123,7 @@ android:layout_height="wrap_content" android:layout_marginBottom="@dimen/content_inset" android:layout_marginTop="@dimen/content_inset_half" - android:background="@color/lighterGray" + android:background="?scriptLayoutBackground" /> - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index d1698c3..a86dbab 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..eb43a7b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..4567198 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index cccfa5b..60056be 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..666c904 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..3baff41 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index a4f8356..27f30d2 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4224797 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1d29a54 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 49dd226..60a8d1a 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7cf19eb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1548eb9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index bd57c86..ac61bd3 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9c43fc9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..04806fd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index db28598..84ad226 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -13,10 +13,4 @@ JavaScript Evaluation - - via PayPal - via Cash App - via Venmo - - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 0df2f69..35d3041 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -4,5 +4,6 @@ + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d69b862..f840cee 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,7 +7,9 @@ #212121 #252525 - #303030 + #303030 + #EEEEEE + #FF6E40 #E44615 #40FF6E40 diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..70daa76 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #758F9A + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b09271..3ac40a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ LinkedIn

Nock Nock is open source! Check out the GitHub page!
Icon by Kevin Aguilar of 221 Pixels. +
View the Privacy Policy. ]]> Dark Mode @@ -29,9 +30,7 @@ Please enter a name! Please enter a URL. Please enter a valid URL. - Please input a validation interval. Please input a search term. - Please input a validation script. Please enter a network timeout greater than 0. Certificate should be a valid file or content URI. @@ -86,14 +85,6 @@ exception to pass custom error messages to Nock Nock. - Donate - Nock Nock was created and is maintained by one person. Donations are much - appreciated and encourage continued support. - ]]> - Thank you very much! - Next - Please install a web browser app, such as Google Chrome. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 1cb4a89..e4b435a 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -6,7 +6,7 @@ diff --git a/app/src/main/res/values/styles_parents.xml b/app/src/main/res/values/styles_parents.xml index decbdb8..dc5e3d6 100644 --- a/app/src/main/res/values/styles_parents.xml +++ b/app/src/main/res/values/styles_parents.xml @@ -9,6 +9,7 @@ #000000 #EEEEEE #000000 + @color/lighterGray #212121 #727272 @@ -33,6 +34,7 @@ #ffffff #303030 #FFFFFF + @color/darkerGray #FFFFFF #F0F0F0 diff --git a/app/src/release/AndroidManifest.xml b/app/src/release/AndroidManifest.xml deleted file mode 100644 index f557bb7..0000000 --- a/app/src/release/AndroidManifest.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - diff --git a/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt index eb86c29..1319310 100644 --- a/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt @@ -17,20 +17,21 @@ package com.afollestad.nocknock.ui.addsite import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.model.Header import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.SiteSettings +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.validation.ValidationExecutor import com.afollestad.nocknock.mockDatabase import com.afollestad.nocknock.utilities.ext.MINUTE import com.afollestad.nocknock.utilities.livedata.test import com.google.common.truth.Truth.assertThat -import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.argumentCaptor import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -149,247 +150,9 @@ class AddSiteViewModelTest { assertThat(viewModel.getValidationArgs()).isEqualTo("Two") } - @Test fun commit_nameError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - name.value = "" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertValues(R.string.please_enter_name) - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_urlEmptyError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - url.value = "" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertValues(R.string.please_enter_url) - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_urlFormatError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - url.value = "ftp://www.idk.com" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertValues(R.string.please_enter_valid_url) - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_networkTimeout_error() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - timeout.value = 0 - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertValues(R.string.please_enter_networkTimeout) - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_checkIntervalError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - checkIntervalValue.value = 0 - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertValues(R.string.please_enter_check_interval) - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_termSearchError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - validationMode.value = TERM_SEARCH - validationSearchTerm.value = "" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertValues(R.string.please_enter_search_term) - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_javaScript_error() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - validationMode.value = JAVASCRIPT - validationScript.value = "" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertValues(R.string.please_enter_javaScript) - - verify(onDone, never()).invoke() - } - @Test fun commit_success() = runBlocking { val isLoading = viewModel.onIsLoading() .test() - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() fillInModel() val onDone = mock<() -> Unit>() @@ -397,31 +160,30 @@ class AddSiteViewModelTest { val siteCaptor = argumentCaptor() val settingsCaptor = argumentCaptor() + val validationResultCaptor = argumentCaptor() isLoading.assertValues(true, false) verify(database.siteDao()).insert(siteCaptor.capture()) verify(database.siteSettingsDao()).insert(settingsCaptor.capture()) - verify(database.validationResultsDao(), never()).insert(any()) + verify(database.validationResultsDao()).insert(validationResultCaptor.capture()) val settings = settingsCaptor.firstValue + val result = validationResultCaptor.firstValue.copy(siteId = 1) val model = siteCaptor.firstValue.copy( id = 1, // fill it in because our insert captor doesn't catch this settings = settings, - lastResult = null + lastResult = result ) + assertThat(result.reason).isNull() + assertThat(result.status).isEqualTo(WAITING) + verify(validationManager).scheduleValidation( site = model, rightNow = true, cancelPrevious = true, fromFinishingJob = false ) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() verify(onDone).invoke() } @@ -435,5 +197,10 @@ class AddSiteViewModelTest { validationScript.value = null checkIntervalValue.value = 60 checkIntervalUnit.value = 1000 + tags.value = "one,two" + headers.value = listOf( + Header(2L, 1L, key = "Content-Type", value = "text/html"), + Header(3L, 1L, key = "User-Agent", value = "NockNock") + ) } } diff --git a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt index 39e017b..47fe6e0 100644 --- a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt @@ -18,6 +18,8 @@ package com.afollestad.nocknock.ui.viewsite import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.afollestad.nocknock.MOCK_MODEL_1 import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.model.Header +import com.afollestad.nocknock.data.model.RetryPolicy import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.SiteSettings import com.afollestad.nocknock.data.model.Status.CHECKING @@ -29,6 +31,7 @@ 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.validation.ValidationExecutor +import com.afollestad.nocknock.fakeRetryPolicy import com.afollestad.nocknock.mockDatabase import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.utilities.livedata.test @@ -38,9 +41,10 @@ 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.verify +import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -255,247 +259,11 @@ class ViewSiteViewModelTest { .isEqualTo("Two") } - @Test fun commit_nameError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - name.value = "" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertValues(R.string.please_enter_name) - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_urlEmptyError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - url.value = "" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertValues(R.string.please_enter_url) - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_urlFormatError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - url.value = "ftp://www.idk.com" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertValues(R.string.please_enter_valid_url) - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_networkTimeout_error() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - timeout.value = 0 - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertValues(R.string.please_enter_networkTimeout) - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_checkIntervalError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - checkIntervalValue.value = 0 - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertValues(R.string.please_enter_check_interval) - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_termSearchError() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - validationMode.value = TERM_SEARCH - validationSearchTerm.value = "" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertValues(R.string.please_enter_search_term) - onScriptError.assertNoValues() - - verify(onDone, never()).invoke() - } - - @Test fun commit_javaScript_error() { - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - - fillInModel().apply { - validationMode.value = JAVASCRIPT - validationScript.value = "" - } - val onDone = mock<() -> Unit>() - viewModel.commit(onDone) - - verify(validationManager, never()) - .scheduleValidation(any(), any(), any(), any()) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertValues(R.string.please_enter_javaScript) - - verify(onDone, never()).invoke() - } - @Test fun commit_success() = runBlocking { + whenever(database.retryPolicyDao().forSite(any())).doReturn(listOf(fakeRetryPolicy(1))) + val isLoading = viewModel.onIsLoading() .test() - val onNameError = viewModel.onNameError() - .test() - val onUrlError = viewModel.onUrlError() - .test() - val onTimeoutError = viewModel.onTimeoutError() - .test() - val onSearchTermError = viewModel.onValidationSearchTermError() - .test() - val onScriptError = viewModel.onValidationScriptError() - .test() - val onCheckIntervalError = viewModel.onCheckIntervalError() - .test() fillInModel() val onDone = mock<() -> Unit>() @@ -506,11 +274,13 @@ class ViewSiteViewModelTest { val siteCaptor = argumentCaptor() val settingsCaptor = argumentCaptor() val resultCaptor = argumentCaptor() + val retryPolicyCaptor = argumentCaptor() isLoading.assertValues(true, false) verify(database.siteDao()).update(siteCaptor.capture()) verify(database.siteSettingsDao()).update(settingsCaptor.capture()) verify(database.validationResultsDao()).update(resultCaptor.capture()) + verify(database.retryPolicyDao()).update(retryPolicyCaptor.capture()) // From fillInModel() below val updatedSettings = MOCK_MODEL_1.settings!!.copy( @@ -523,11 +293,13 @@ class ViewSiteViewModelTest { val updatedResult = MOCK_MODEL_1.lastResult!!.copy( status = WAITING ) + val retryPolicy = retryPolicyCaptor.firstValue val updatedModel = MOCK_MODEL_1.copy( name = "Hello There", url = "https://www.hellothere.com", settings = updatedSettings, - lastResult = updatedResult + lastResult = updatedResult, + retryPolicy = retryPolicy ) assertThat(siteCaptor.firstValue).isEqualTo(updatedModel) @@ -541,13 +313,6 @@ class ViewSiteViewModelTest { fromFinishingJob = false ) - onNameError.assertNoValues() - onUrlError.assertNoValues() - onTimeoutError.assertNoValues() - onCheckIntervalError.assertNoValues() - onSearchTermError.assertNoValues() - onScriptError.assertNoValues() - verify(onDone).invoke() } @@ -619,5 +384,12 @@ class ViewSiteViewModelTest { validationScript.value = "throw 'Oh no!'" checkIntervalValue.value = 24 checkIntervalUnit.value = 60000 + tags.value = "one,two" + retryPolicyTimes.value = 5 + retryPolicyMinutes.value = 5 + headers.value = listOf( + Header(2L, 1L, key = "Content-Type", value = "text/html"), + Header(3L, 1L, key = "User-Agent", value = "NockNock") + ) } } diff --git a/build.gradle b/build.gradle index d99c0ee..2d8e462 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,7 @@ buildscript { classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin classpath 'com.github.ben-manes:gradle-versions-plugin:' + versions.versionPlugin classpath 'io.fabric.tools:gradle:' + versions.fabricPlugin + classpath 'com.google.gms:google-services:' + versions.googleServices } } @@ -22,7 +23,6 @@ allprojects { repositories { google() jcenter() - maven { url "https://dl.bintray.com/drummer-aidan/maven" } maven { url "https://jitpack.io" } } diff --git a/common/build.gradle b/common/build.gradle index 8472928..5004349 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -12,6 +12,10 @@ android { versionName versions.publishVersion } + packagingOptions { + exclude 'META-INF/atomicfu.kotlin_module' + } + // For Mozilla Rhino lintOptions { abortOnError false @@ -30,6 +34,7 @@ dependencies { implementation 'org.mozilla:rhino:' + versions.rhino api 'com.afollestad:rxkprefs:' + versions.rxkPrefs + api "io.reactivex.rxjava2:rxjava:" + versions.rxJava testImplementation 'junit:junit:' + versions.junit testImplementation 'com.google.truth:truth:' + versions.truth diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt index 2fa66dc..1256a27 100644 --- a/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt +++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt @@ -18,3 +18,10 @@ package com.afollestad.nocknock.utilities.ext import android.net.Uri fun String.toUri() = Uri.parse(this)!! + +fun String?.isNotNullOrEmpty(): Boolean { + if (this == null || this == "null") { + return false + } + return !isNullOrEmpty() +} diff --git a/data/build.gradle b/data/build.gradle index 5512756..e413d4c 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -14,6 +14,10 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + + packagingOptions { + exclude 'META-INF/atomicfu.kotlin_module' + } } dependencies { diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt index 463be68..e7d4400 100644 --- a/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt +++ b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt @@ -181,7 +181,8 @@ class AppDatabaseTest() { validationMode = STATUS_CODE, validationArgs = null, disabled = false, - networkTimeout = 10000 + networkTimeout = 10000, + certificate = null ) val newId = settingsDao.insert(model) assertThat(newId).isEqualTo(1) @@ -199,7 +200,8 @@ class AppDatabaseTest() { validationMode = STATUS_CODE, validationArgs = null, disabled = false, - networkTimeout = 10000 + networkTimeout = 10000, + certificate = null ) ) @@ -227,7 +229,8 @@ class AppDatabaseTest() { validationMode = STATUS_CODE, validationArgs = null, disabled = false, - networkTimeout = 10000 + networkTimeout = 10000, + certificate = null ) ) @@ -428,9 +431,30 @@ class AppDatabaseTest() { val allSites = db.allSites() assertThat(allSites.size).isEqualTo(3) - assertThat(allSites[0]).isEqualTo(MOCK_MODEL_1) - assertThat(allSites[1]).isEqualTo(MOCK_MODEL_2) - assertThat(allSites[2]).isEqualTo(MOCK_MODEL_3) + assertThat(allSites[0]).isEqualTo( + MOCK_MODEL_1.copy( + headers = listOf( + MOCK_MODEL_1.headers.first().copy(id = 1), + MOCK_MODEL_1.headers.last().copy(id = 2) + ) + ) + ) + assertThat(allSites[1]).isEqualTo( + MOCK_MODEL_2.copy( + headers = listOf( + MOCK_MODEL_2.headers.first().copy(id = 3), + MOCK_MODEL_2.headers.last().copy(id = 4) + ) + ) + ) + assertThat(allSites[2]).isEqualTo( + MOCK_MODEL_3.copy( + headers = listOf( + MOCK_MODEL_3.headers.first().copy(id = 5), + MOCK_MODEL_3.headers.last().copy(id = 6) + ) + ) + ) } @Test fun extension_put_getSite() { @@ -467,10 +491,12 @@ class AppDatabaseTest() { ) val updatedHeaders = listOf( modelToUpdate.headers.first().copy( + id = 7, key = "One", value = "Hello" ), modelToUpdate.headers.last().copy( + id = 8, key = "Two", value = "Hey" ) diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt index 6fc9796..2866b50 100644 --- a/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt +++ b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt @@ -35,7 +35,8 @@ fun fakeSettingsModel( validationMode = validationMode, validationArgs = null, disabled = false, - networkTimeout = 10000 + networkTimeout = 10000, + certificate = null ) fun fakeResultModel( diff --git a/dependencies.gradle b/dependencies.gradle index b6b8781..e0e2412 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -3,52 +3,56 @@ ext.versions = [ minSdk : 21, compileSdk : 28, buildTools : '28.0.3', - publishVersion : '0.8.5', - publishVersionCode : 39, + publishVersion : '0.8.8', + publishVersionCode : 46, // Plugins - gradlePlugin : '3.2.1', - spotlessPlugin : '3.17.0', - versionPlugin : '0.20.0', + gradlePlugin : '3.4.0', + spotlessPlugin : '3.22.0', + versionPlugin : '0.21.0', + googleServices : '4.2.0', fabricPlugin : '1.+', // Misc - okHttp : '3.12.1', + okHttp : '3.14.1', rhino : '1.7.10', // Kotlin - kotlin : '1.3.11', - coroutines : '1.1.0', + kotlin : '1.3.30', + coroutines : '1.2.0', koin : '1.0.2', // Google/AndroidX - androidxAnnotations : '1.0.1', + androidxAnnotations : '1.0.2', androidxCore : '1.0.2', androidxRecyclerView: '1.0.0', androidxBrowser : '1.0.0', googleMaterial : '1.0.0', room : '2.0.0', lifecycle : '2.0.0', + firebaseCore : '16.0.8', // Rx + rxJava : '2.2.8', rxBinding : '3.0.0-alpha1', // afollestad - materialDialogs : '2.0.0-rc7', - rxkPrefs : '1.2.1', + materialDialogs : '2.8.1', + rxkPrefs : '1.2.5', + vvalidator : '0.4.1', // Debugging timber : '4.7.1', - fabric : '2.9.8@aar', + fabric : '2.9.9@aar', // Unit testing junit : '4.12', - mockito : '2.23.4', - mockitoKotlin : '2.0.0-RC1', - truth : '0.42', + mockito : '2.27.0', + mockitoKotlin : '2.1.0', + truth : '0.44', // UI testing androidxTestRunner : '1.1.1', androidxTest : '1.1.0', - archTesting : '2.0.0' + archTesting : '2.0.1' ] diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt index a9fb397..d47d616 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt @@ -26,6 +26,7 @@ import com.afollestad.nocknock.data.model.Status.OK import com.afollestad.nocknock.engine.R import com.afollestad.nocknock.engine.ssl.SslManager import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID +import com.afollestad.nocknock.utilities.ext.isNotNullOrEmpty import com.afollestad.nocknock.utilities.providers.BundleProvider import com.afollestad.nocknock.utilities.providers.JobInfoProvider import com.afollestad.nocknock.utilities.providers.StringProvider @@ -35,6 +36,7 @@ import okhttp3.Response import org.jetbrains.annotations.TestOnly import java.net.SocketTimeoutException import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.math.max import timber.log.Timber.d as log /** @author Aidan Follestad (@afollestad) */ @@ -153,22 +155,24 @@ class RealValidationExecutor( check(site.id != 0L) { "Cannot schedule validations for jobs with no ID." } val siteSettings = site.settings requireNotNull(siteSettings) { "Site settings must be populated." } - check(siteSettings.networkTimeout > 0) { "Network timeout not set for site ${site.id}" } log("performValidation(${site.id}) - GET ${site.url}") val request = Request.Builder() .apply { url(site.url) get() - site.headers.forEach { header -> - addHeader(header.key, header.value) - } + site.headers + .filter { header -> header.key.isNotNullOrEmpty() } + .forEach { header -> + addHeader(header.key, header.value) + } } .build() return try { - val clientWithTimeout = clientTimeoutChanger(okHttpClient, siteSettings.networkTimeout) - val client = if (!siteSettings.certificate.isNullOrEmpty()) { + val timeout = max(siteSettings.networkTimeout, 1) + val clientWithTimeout = clientTimeoutChanger(okHttpClient, timeout) + val client = if (siteSettings.certificate.isNotNullOrEmpty()) { sslManager.clientForCertificate( certUri = siteSettings.certificate!!, siteUri = site.url, @@ -205,6 +209,7 @@ class RealValidationExecutor( ) ) } catch (ex: Exception) { + ex.printStackTrace() log("performValidation(${site.id}) = Error: ${ex.message}") CheckResult(model = site.withStatus(status = ERROR, reason = ex.message)) } diff --git a/gradle.properties b/gradle.properties index 9516cf5..c62d03d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,6 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryErro # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true + +android.useAndroidX=true +android.enableJetifier=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1c0396b..2128899 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip diff --git a/ic_web.png b/ic_web.png index 509177f..d54b6ec 100644 Binary files a/ic_web.png and b/ic_web.png differ diff --git a/mock/mock-google-services.json b/mock/mock-google-services.json new file mode 100644 index 0000000..d9866d8 --- /dev/null +++ b/mock/mock-google-services.json @@ -0,0 +1,42 @@ +{ + "project_info": { + "project_number": "123456789000", + "firebase_url": "https://mockproject-1234.firebaseio.com", + "project_id": "mockproject-1234", + "storage_bucket": "mockproject-1234.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063", + "android_client_info": { + "package_name": "com.afollestad.nocknock" + } + }, + "oauth_client": [ + { + "client_id": "123456789000-hjugbg6ud799v4c49dim8ce2usclthar.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzbSzCn1N6LWIe6wthYyrgUUSAlUsdqMb-wvTo" + } + ], + "services": { + "analytics_service": { + "status": 1 + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 2 + } + } + } + ], + "configuration_version": "1" +} diff --git a/mock/mock.gradle b/mock/mock.gradle new file mode 100644 index 0000000..2fa58ef --- /dev/null +++ b/mock/mock.gradle @@ -0,0 +1,13 @@ +// This script must be applied in app/build.gradle for the paths here to work correctly + +def copyMockFilesNeeded() { + def srcGoogleServicesFile = file("../mock/mock-google-services.json") + def destGoogleServicesFile = file("google-services.json") + if (!destGoogleServicesFile.exists()) { + destGoogleServicesFile.write(srcGoogleServicesFile.text) + } +} + +afterEvaluate { + copyMockFilesNeeded() +} \ No newline at end of file diff --git a/viewcomponents/build.gradle b/viewcomponents/build.gradle index 8b7cc90..a3ae3dc 100644 --- a/viewcomponents/build.gradle +++ b/viewcomponents/build.gradle @@ -18,6 +18,8 @@ dependencies { implementation project(':common') implementation project(':data') + api 'com.afollestad:vvalidator:' + versions.vvalidator + implementation 'androidx.appcompat:appcompat:' + versions.androidxCore implementation 'com.google.android.material:material:' + versions.googleMaterial api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/ViewExt.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/ViewExt.kt index feeef85..e543863 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/ViewExt.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/ViewExt.kt @@ -17,37 +17,24 @@ package com.afollestad.nocknock.viewcomponents.ext import android.view.View import android.view.View.GONE -import android.view.View.INVISIBLE import android.view.View.VISIBLE -import android.view.ViewTreeObserver import androidx.annotation.DimenRes +import com.afollestad.vvalidator.form.Condition fun View.show() { visibility = VISIBLE } -fun View.conceal() { - visibility = INVISIBLE -} - fun View.hide() { visibility = GONE } fun View.showOrHide(show: Boolean) = if (show) show() else hide() -fun View.onLayout(cb: () -> Unit) { - if (this.viewTreeObserver.isAlive) { - this.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - cb() - this@onLayout.viewTreeObserver.removeOnGlobalLayoutListener(this) - } - }) - } -} - fun View.dimenFloat(@DimenRes res: Int) = resources.getDimension(res) fun View.dimenInt(@DimenRes res: Int) = resources.getDimensionPixelSize(res) + +fun View.isVisibleCondition(): Condition = { + visibility == VISIBLE +} diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt index 092d416..6abff33 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt @@ -56,9 +56,22 @@ class HeaderStackLayout( override fun onClick(v: View) { val index = v.tag as Int - list.removeViewAt(index) - headers.removeAt(index) - postLiveData() + check(index >= 0 || index < list.childCount) { + "Index $index is out of bounds in the header stack (size ${list.childCount})." + } + list.post { + list.removeViewAt(index) + headers.removeAt(index) + invalidateTags() + postLiveData() + } + } + + private fun invalidateTags() { + for (i in 0 until list.childCount) { + val entry = list.getChildAt(i) as HeaderItemLayout + entry.btnRemove.tag = i + } } private fun addEntry(forHeader: Header) { @@ -67,9 +80,7 @@ class HeaderStackLayout( val li = LayoutInflater.from(context) val entry = li.inflate(R.layout.header_stack_item, list, false) as HeaderItemLayout - list.addView(entry) - - entry.run { + list.addView(entry.apply { inputKey.setText(forHeader.key) inputKey.post { entry.inputKey.requestFocus() } attachHeader(forHeader, this@HeaderStackLayout) @@ -77,6 +88,6 @@ class HeaderStackLayout( btnRemove.tag = headers.size - 1 btnRemove.setOnClickListener(this@HeaderStackLayout) - } + }) } } diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt index 3436dd5..2db60fe 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt @@ -19,7 +19,6 @@ import android.content.Context import android.util.AttributeSet import android.widget.ArrayAdapter import android.widget.LinearLayout -import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.afollestad.nocknock.utilities.ext.DAY import com.afollestad.nocknock.utilities.ext.HOUR @@ -28,7 +27,7 @@ import com.afollestad.nocknock.utilities.ext.WEEK import com.afollestad.nocknock.viewcomponents.R import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner -import com.afollestad.nocknock.viewcomponents.livedata.toViewError +import com.afollestad.vvalidator.form.Form import kotlinx.android.synthetic.main.validation_interval_layout.view.input import kotlinx.android.synthetic.main.validation_interval_layout.view.spinner @@ -66,8 +65,14 @@ class ValidationIntervalLayout( fun attach( valueData: MutableLiveData, multiplierData: MutableLiveData, - errorData: LiveData + form: Form ) { + form.input(input, name = "Interval") { + isNotEmpty().description(R.string.please_enter_check_interval) + length().greaterThan(0) + .description(R.string.check_interval_must_be_greater_zero) + } + input.attachLiveData(lifecycleOwner(), valueData) spinner.attachLiveData( lifecycleOwner = lifecycleOwner(), @@ -91,10 +96,5 @@ class ValidationIntervalLayout( } } ) - errorData.toViewError(lifecycleOwner(), this, ::setError) - } - - private fun setError(error: String?) { - input.error = error } } diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt index 5d32e69..1b1a29f 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt @@ -20,15 +20,17 @@ import android.util.AttributeSet import android.widget.HorizontalScrollView import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import com.afollestad.nocknock.viewcomponents.R import com.afollestad.nocknock.viewcomponents.R.dimen import com.afollestad.nocknock.viewcomponents.R.layout import com.afollestad.nocknock.viewcomponents.ext.dimenFloat import com.afollestad.nocknock.viewcomponents.ext.dimenInt +import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition import com.afollestad.nocknock.viewcomponents.ext.showOrHide import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner -import com.afollestad.nocknock.viewcomponents.livedata.toViewError import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility +import com.afollestad.vvalidator.form.Form import kotlinx.android.synthetic.main.javascript_input_layout.view.error_text import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput @@ -50,16 +52,25 @@ class JavaScriptInputLayout( contentInset // bottom ) elevation = dimenFloat(dimen.default_elevation) - inflate(context, layout.javascript_input_layout, this) + inflate(context, R.layout.javascript_input_layout, this) } fun attach( codeData: MutableLiveData, - errorData: LiveData, - visibility: LiveData + visibility: LiveData, + form: Form ) { + form.input(userInput, name = "Script") { + conditional(isVisibleCondition()) { + isNotEmpty().description(R.string.please_enter_javaScript) + } + onErrors { _, errors -> + val error = errors.firstOrNull() + setError(error.toString()) + } + } + userInput.attachLiveData(lifecycleOwner(), codeData) - errorData.toViewError(lifecycleOwner(), this, ::setError) visibility.toViewVisibility(lifecycleOwner(), this) } diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/retrypolicy/RetryPolicyLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/retrypolicy/RetryPolicyLayout.kt index 95e84d0..5a9375d 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/retrypolicy/RetryPolicyLayout.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/retrypolicy/RetryPolicyLayout.kt @@ -24,6 +24,7 @@ import com.afollestad.nocknock.viewcomponents.R import com.afollestad.nocknock.viewcomponents.ext.asSafeInt import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner +import com.afollestad.vvalidator.form.Form import kotlinx.android.synthetic.main.retry_policy_layout.view.minutes import kotlinx.android.synthetic.main.retry_policy_layout.view.times import kotlinx.android.synthetic.main.retry_policy_layout.view.retry_policy_desc as description @@ -41,7 +42,8 @@ class RetryPolicyLayout( fun attach( timesData: MutableLiveData, - minutesData: MutableLiveData + minutesData: MutableLiveData, + form: Form ) { times.attachLiveData(lifecycleOwner(), timesData) minutes.attachLiveData(lifecycleOwner(), minutesData) @@ -50,6 +52,13 @@ class RetryPolicyLayout( minutes.onTextChanged { invalidateDescriptionText() } invalidateDescriptionText() + + form.input(times, optional = true) { + isNumber().greaterThan(0) + } + form.input(minutes, optional = true) { + isNumber().greaterThan(0) + } } private fun invalidateDescriptionText() { diff --git a/viewcomponents/src/main/res/values/strings.xml b/viewcomponents/src/main/res/values/strings.xml index b2462ec..a8f5d3f 100644 --- a/viewcomponents/src/main/res/values/strings.xml +++ b/viewcomponents/src/main/res/values/strings.xml @@ -23,4 +23,8 @@ Header Name Header Value + Please input a validation interval. + The validation interval must be greater than 0. + Please input a validation script. +