Compare commits

...

55 commits

Author SHA1 Message Date
Aidan Follestad
23ba4a69cd
Delete .travis.yml 2020-02-24 11:50:16 -08:00
Aidan Follestad
dd9aec1dff
Update README.md 2020-02-24 11:50:05 -08:00
Aidan Follestad
406af590aa Increment version code 2019-04-19 10:52:10 -07:00
Aidan Follestad
550f8c59be Adaptive-ish icon 2019-04-19 10:51:54 -07:00
Aidan Follestad
10d7fe33f9 Fix a few test cases 2019-04-18 19:09:08 -07:00
Aidan Follestad
35eda8f057 Exclude META-INF/atomicfu.kotlin_module from data module 2019-04-18 18:35:20 -07:00
Aidan Follestad
a0fd44ae7a Exclude META-INF/atomicfu.kotlin_module from common module 2019-04-18 18:06:27 -07:00
Aidan Follestad
351f718df8 0.8.8 2019-04-18 17:57:20 -07:00
Aidan Follestad
e2f7db22d1 Still trying to fix Travis 2019-04-18 17:48:27 -07:00
Aidan Follestad
82c1a17c68 Fix crash when mremoving headers, resolves #48 2019-04-18 17:36:49 -07:00
Aidan Follestad
a6670e2bea Fix Travis build 2019-04-18 17:20:09 -07:00
Aidan Follestad
5fc1569099 Remove donation options 2019-04-18 16:19:10 -07:00
Aidan Follestad
0770db5df5 Attempt to fix Travis.ci running UI tests 2019-04-18 16:12:06 -07:00
Aidan Follestad
97a0eda92c Dep and Gradle Plugin updates 2019-04-18 16:07:24 -07:00
Aidan Follestad
1ccb89bfc3 Dependency upgrades 2019-04-15 20:46:36 -07:00
Aidan Follestad
9ea9c78099 Add privacy policy link to the about dialog 2019-04-15 20:37:35 -07:00
Aidan Follestad
997c797598
Kotlin 1.3.30 2019-04-11 12:56:23 -07:00
Aidan Follestad
b26543d244
Update dependencies.gradle 2019-03-16 12:08:29 -07:00
Aidan Follestad
8c3654c4ac 0.8.7 2019-03-14 15:06:38 -07:00
Aidan Follestad
df2652860e -am 2019-03-14 15:06:03 -07:00
Aidan Follestad
4da8cb5f11 Fix tests 2019-03-14 15:01:24 -07:00
Aidan Follestad
334e9e823c Switch to Firebase for Crashlytics 2019-03-14 13:55:19 -07:00
Aidan Follestad
6d382b93a5 Dependency updates 2019-02-21 14:43:26 -08:00
Aidan Follestad
ef18464728
vvalidator 0.3.0 2019-02-01 17:57:23 -08:00
Aidan Follestad
872e99d80d Custom repo not needed anymore for Material Dialogs 2019-02-01 15:21:11 -08:00
Aidan Follestad
7f507792a8
Update gradle-wrapper.properties 2019-01-30 13:53:10 -08:00
Aidan Follestad
68b6944542 0.8.6c 2019-01-26 18:42:16 -08:00
Aidan Follestad
e39093b526 Fix script input layout background color 2019-01-26 18:41:55 -08:00
Aidan Follestad
9514a5ec83 0.8.6b 2019-01-26 14:55:26 -08:00
Aidan Follestad
3e5b1d4d8e Resolve two crashes 2019-01-26 14:55:06 -08:00
Aidan Follestad
de59bf9ec1 0.8.6 2019-01-26 14:41:02 -08:00
Aidan Follestad
0fbd27b54b Fixed some data population issues 2019-01-26 14:35:34 -08:00
Aidan Follestad
33388bd5c2 Fixes around default SSL cert setting 2019-01-26 14:27:55 -08:00
Aidan Follestad
75297c7ff5 Fixes to vvalidator setup 2019-01-26 14:22:22 -08:00
Aidan Follestad
c6fca52fe4 Use vvalidator in the view site page 2019-01-26 14:10:58 -08:00
Aidan Follestad
b3f8a43f71 Use vvalidator in the add site page 2019-01-26 13:55:34 -08:00
Aidan Follestad
7dc4ee7fb1 Add vvalidator dep 2019-01-26 12:39:46 -08:00
Aidan Follestad
859dcb53ca MD rc9 2019-01-26 12:36:20 -08:00
Aidan Follestad
f86ccbbe0c Add default Fabric props that should fix CI 2019-01-25 11:08:18 -08:00
Aidan Follestad
571e7ebff3 Pull Fabric props from environment variables, default to 0 which stops build failures 2019-01-24 16:03:12 -08:00
Aidan Follestad
77f939b095 Lots of dependency upgrades 2019-01-24 14:32:38 -08:00
Aidan Follestad
8f16ff2d33 AppDatabaseTest fix 2019-01-13 15:16:33 -08:00
Aidan Follestad
4f5fec758e 0.8.5 2019-01-11 22:09:06 -08:00
Aidan Follestad
b369f9dfd3 Update MainViewModelTest 2019-01-11 21:03:57 -08:00
Aidan Follestad
38c8c92c1c Fix yet another test folder 2019-01-11 20:05:32 -08:00
Aidan Follestad
6ae85ea061 Update NockNotificationManagerTest 2019-01-11 20:05:19 -08:00
Aidan Follestad
34329f3a9f Fix another test folder 2019-01-11 19:57:52 -08:00
Aidan Follestad
6bb131fb23 Update ValidationExecutorTest 2019-01-11 19:57:29 -08:00
Aidan Follestad
8535a6fe8b Fix test folder 2019-01-11 19:08:33 -08:00
Aidan Follestad
cd1651672f Basic certificate URI validation 2019-01-11 19:07:51 -08:00
Aidan Follestad
26d6d9abf8 Re-organize some UI, hook up SSL certificate selection, etc. Resolves #42. 2019-01-11 18:50:08 -08:00
Aidan Follestad
909e5420ad Integrate SslManager into the ValidationExecutor 2019-01-11 18:12:55 -08:00
Aidan Follestad
55ea6674e6 Add SslManager 2019-01-11 17:56:21 -08:00
Aidan Follestad
2221c45789 Use big text notification style for error notifications 2019-01-11 17:43:24 -08:00
Aidan Follestad
deae0f0dc2 Update the showcase image 2019-01-08 17:00:16 -08:00
86 changed files with 1335 additions and 1138 deletions

View file

@ -1,3 +0,0 @@
[*.kt]
indent_size = 2
continuation_indent_size=4

2
.gitignore vendored
View file

@ -181,3 +181,5 @@ gradle-app.setting
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
# gradle/wrapper/gradle-wrapper.properties # gradle/wrapper/gradle-wrapper.properties
app/google-services.json

2
.idea/misc.xml generated
View file

@ -40,7 +40,7 @@
</value> </value>
</option> </option>
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View file

@ -1,19 +0,0 @@
language: android
jdk: oraclejdk8
before_script:
- echo no | android create avd --force -n test -t android-22 --abi armeabi-v7a
- emulator -avd test -no-audio -no-window &
- android-wait-for-emulator
- adb shell input keyevent 82 &
android:
components:
- tools
- platform-tools
- build-tools-28.0.3
- android-28
- extra-android-support
- extra-android-m2repository
- extra-google-m2repository
licenses:
- '.+'

View file

@ -1,9 +1,8 @@
## Nock Nock ## 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) [![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/showcasemain4.png) ![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png)
Nock Nock is a simple app which allows you to monitor your websites for maximum uptime. Nock Nock is a simple app which allows you to monitor your websites for maximum uptime.

View file

@ -4,18 +4,6 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions' 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 { android {
compileSdkVersion versions.compileSdk compileSdkVersion versions.compileSdk
buildToolsVersion versions.buildTools buildToolsVersion versions.buildTools
@ -26,16 +14,15 @@ android {
targetSdkVersion versions.compileSdk targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode versionCode versions.publishVersionCode
versionName versions.publishVersion versionName versions.publishVersion
manifestPlaceholders = [fabricKey:getFabricApiKey()]
} }
buildTypes { compileOptions {
debug { sourceCompatibility 1.8
buildConfigField "String", "FABRIC_API_KEY", "\"\"" targetCompatibility 1.8
}
release {
buildConfigField "String", "FABRIC_API_KEY", "\"${getFabricApiKey()}\""
} }
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
} }
} }
@ -51,6 +38,7 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView
implementation 'com.google.android.material:material:' + versions.googleMaterial implementation 'com.google.android.material:material:' + versions.googleMaterial
implementation 'androidx.browser:browser:' + versions.androidxBrowser implementation 'androidx.browser:browser:' + versions.androidxBrowser
implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore
// Lifecycle // Lifecycle
kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle
@ -85,3 +73,7 @@ dependencies {
} }
apply from: '../spotless.gradle' apply from: '../spotless.gradle'
apply from: '../mock/mock.gradle'
apply plugin: "io.fabric"
apply plugin: 'com.google.gms.google-services'

View file

@ -50,9 +50,6 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<meta-data
android:name="io.fabric.ApiKey"
android:value="${fabricKey}"/>
<meta-data <meta-data
android:name="preloaded_fonts" android:name="preloaded_fonts"
android:resource="@array/preloaded_fonts"/> android:resource="@array/preloaded_fonts"/>

View file

@ -20,12 +20,12 @@ import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks import android.app.Application.ActivityLifecycleCallbacks
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import androidx.core.text.HtmlCompat.fromHtml import androidx.core.text.HtmlCompat.fromHtml
import com.afollestad.materialdialogs.utils.MDUtil.resolveColor import com.afollestad.materialdialogs.utils.MDUtil.resolveColor
import com.afollestad.nocknock.utilities.ext.toUri
import com.afollestad.nocknock.utilities.ui.toast import com.afollestad.nocknock.utilities.ui.toast
typealias ActivityLifeChange = (activity: Activity, resumed: Boolean) -> Unit typealias ActivityLifeChange = (activity: Activity, resumed: Boolean) -> Unit
@ -57,8 +57,6 @@ fun Application.onActivityLifeChange(cb: ActivityLifeChange) {
fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY) fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY)
fun String.toUri() = Uri.parse(this)!!
fun Activity.viewUrl(url: String) { fun Activity.viewUrl(url: String) {
val customTabsIntent = CustomTabsIntent.Builder() val customTabsIntent = CustomTabsIntent.Builder()
.apply { .apply {

View file

@ -47,10 +47,8 @@ class NockNockApp : Application() {
Timber.plant(DebugTree()) Timber.plant(DebugTree())
} }
if (BuildConfig.FABRIC_API_KEY.isNotEmpty()) {
Timber.plant(FabricTree()) Timber.plant(FabricTree())
Fabric.with(this, Crashlytics()) Fabric.with(this, Crashlytics())
}
val modules = listOf( val modules = listOf(
prefModule, prefModule,

View file

@ -25,6 +25,7 @@ import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.Database1to2Migration import com.afollestad.nocknock.data.Database1to2Migration
import com.afollestad.nocknock.data.Database2to3Migration import com.afollestad.nocknock.data.Database2to3Migration
import com.afollestad.nocknock.data.Database3to4Migration import com.afollestad.nocknock.data.Database3to4Migration
import com.afollestad.nocknock.data.Database4to5Migration
import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS
import com.afollestad.nocknock.ui.main.MainActivity import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.utilities.ext.systemService import com.afollestad.nocknock.utilities.ext.systemService
@ -43,7 +44,8 @@ val mainModule = module {
.addMigrations( .addMigrations(
Database1to2Migration(), Database1to2Migration(),
Database2to3Migration(), Database2to3Migration(),
Database3to4Migration() Database3to4Migration(),
Database4to5Migration()
) )
.build() .build()
} }

View file

@ -15,10 +15,15 @@
*/ */
package com.afollestad.nocknock.ui package com.afollestad.nocknock.ui
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.afollestad.nocknock.R import com.afollestad.nocknock.R
import com.afollestad.nocknock.koin.PREF_DARK_MODE 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.nocknock.utilities.rx.attachLifecycle
import com.afollestad.rxkprefs.Pref import com.afollestad.rxkprefs.Pref
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@ -35,6 +40,7 @@ abstract class DarkModeSwitchActivity : AppCompatActivity() {
setTheme(themeRes()) setTheme(themeRes())
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (getCurrentNightMode() == UNKNOWN) {
darkModePref.observe() darkModePref.observe()
.filter { it != isDarkModeEnabled } .filter { it != isDarkModeEnabled }
.subscribe { .subscribe {
@ -43,8 +49,26 @@ abstract class DarkModeSwitchActivity : AppCompatActivity() {
} }
.attachLifecycle(this) .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()) protected fun toggleDarkMode() = setDarkMode(!isDarkMode())

View file

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

View file

@ -16,19 +16,28 @@
package com.afollestad.nocknock.ui.addsite package com.afollestad.nocknock.ui.addsite
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.os.Bundle import android.os.Bundle
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.lifecycle.Observer
import com.afollestad.nocknock.R import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.ui.DarkModeSwitchActivity import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.ui.viewsite.KEY_SITE import com.afollestad.nocknock.ui.viewsite.KEY_SITE
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.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData 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.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility 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.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.headersLayout import kotlinx.android.synthetic.main.activity_addsite.headersLayout
import kotlinx.android.synthetic.main.activity_addsite.inputName import kotlinx.android.synthetic.main.activity_addsite.inputName
@ -41,6 +50,8 @@ import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchT
import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_addsite.scrollView import kotlinx.android.synthetic.main.activity_addsite.scrollView
import kotlinx.android.synthetic.main.activity_addsite.sslCertificateBrowse
import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput
import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
import kotlinx.android.synthetic.main.include_app_bar.toolbar import kotlinx.android.synthetic.main.include_app_bar.toolbar
@ -50,14 +61,19 @@ import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTi
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
class AddSiteActivity : DarkModeSwitchActivity() { class AddSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
private val viewModel by viewModel<AddSiteViewModel>() private val viewModel by viewModel<AddSiteViewModel>()
private lateinit var validationForm: Form
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_addsite) setContentView(R.layout.activity_addsite)
setupUi() setupUi()
setupValidation()
lifecycle.addObserver(viewModel) lifecycle.addObserver(viewModel)
@ -70,23 +86,17 @@ class AddSiteActivity : DarkModeSwitchActivity() {
// Name // Name
inputName.attachLiveData(this, viewModel.name) inputName.attachLiveData(this, viewModel.name)
viewModel.onNameError()
.toViewError(this, inputName)
// Tags // Tags
inputTags.attachLiveData(this, viewModel.tags) inputTags.attachLiveData(this, viewModel.tags)
// Url // Url
inputUrl.attachLiveData(this, viewModel.url) inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlError()
.toViewError(this, inputUrl)
viewModel.onUrlWarningVisibility() viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning) .toViewVisibility(this, textUrlWarning)
// Timeout // Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout) responseTimeoutInput.attachLiveData(this, viewModel.timeout)
viewModel.onTimeoutError()
.toViewError(this, responseTimeoutInput)
// Validation mode // Validation mode
responseValidationMode.attachLiveData( responseValidationMode.attachLiveData(
@ -95,8 +105,6 @@ class AddSiteActivity : DarkModeSwitchActivity() {
outTransformer = { ValidationMode.fromIndex(it) }, outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() } inTransformer = { it.toIndex() }
) )
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription() viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription) .toViewText(this, validationModeDescription)
@ -109,25 +117,10 @@ class AddSiteActivity : DarkModeSwitchActivity() {
viewModel.onValidationSearchTermVisibility() viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm) .toViewVisibility(this, responseValidationSearchTerm)
// Validation script // SSL certificate
scriptInputLayout.attach( sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
codeData = viewModel.validationScript, viewModel.certificateUri.distinct()
errorData = viewModel.onValidationScriptError(), .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
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
)
// Headers // Headers
headersLayout.attach(viewModel.headers) headersLayout.attach(viewModel.headers)
@ -137,15 +130,6 @@ class AddSiteActivity : DarkModeSwitchActivity() {
toolbarTitle.setText(R.string.add_site) toolbarTitle.setText(R.string.add_site)
toolbar.run { toolbar.run {
inflateMenu(R.menu.menu_addsite) 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) setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() } setNavigationOnClickListener { finish() }
} }
@ -165,5 +149,87 @@ class AddSiteActivity : DarkModeSwitchActivity() {
0f 0f
} }
} }
// SSL certificate
sslCertificateBrowse.setOnClickListener {
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
addCategory(CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
}
}
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) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
}
} }
} }

View file

@ -40,7 +40,6 @@ import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.ui.ScopedViewModel import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.ext.MINUTE import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.livedata.map import com.afollestad.nocknock.utilities.livedata.map
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -68,6 +67,7 @@ class AddSiteViewModel(
val retryPolicyTimes = MutableLiveData<Int>() val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>() val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>() val headers = MutableLiveData<List<Header>>()
val certificateUri = MutableLiveData<String>()
@OnLifecycleEvent(ON_START) @OnLifecycleEvent(ON_START)
fun setDefaults() { fun setDefaults() {
@ -81,22 +81,10 @@ class AddSiteViewModel(
headers.value = emptyList() headers.value = emptyList()
} }
// Private properties
private val isLoading = MutableLiveData<Boolean>() 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 onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onNameError(): LiveData<Int?> = nameError
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> { @CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map { return url.map {
val parsed = HttpUrl.parse(it) val parsed = HttpUrl.parse(it)
@ -104,8 +92,6 @@ class AddSiteViewModel(
} }
} }
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
@CheckResult fun onValidationModeDescription(): LiveData<Int> { @CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map { return validationMode.map {
when (it!!) { when (it!!) {
@ -116,17 +102,9 @@ class AddSiteViewModel(
} }
} }
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationSearchTermVisibility() = @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
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 // Actions
fun commit(done: () -> Unit) { fun commit(done: () -> Unit) {
@ -166,70 +144,7 @@ class AddSiteViewModel(
} }
private fun generateDbModel(): Site? { 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 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
}
if (errorCount > 0) {
return null
}
val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
val newSettings = SiteSettings( val newSettings = SiteSettings(
@ -237,7 +152,8 @@ class AddSiteViewModel(
validationMode = validationMode.value!!, validationMode = validationMode.value!!,
validationArgs = getValidationArgs(), validationArgs = getValidationArgs(),
networkTimeout = timeout, networkTimeout = timeout,
disabled = false disabled = false,
certificate = certificateUri.value?.toString()
) )
val newLastResult = ValidationResult( val newLastResult = ValidationResult(
@ -250,7 +166,8 @@ class AddSiteViewModel(
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
RetryPolicy( RetryPolicy(
count = retryPolicyTimes, minutes = retryPolicyMinutes count = retryPolicyTimes,
minutes = retryPolicyMinutes
) )
} else { } else {
null null

View file

@ -24,7 +24,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItems import com.afollestad.materialdialogs.list.listItems
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.afollestad.nocknock.R import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.SiteAdapter import com.afollestad.nocknock.adapter.SiteAdapter
import com.afollestad.nocknock.adapter.TagAdapter 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.dialogs.AboutDialog
import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.DarkModeSwitchActivity 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.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 com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list import kotlinx.android.synthetic.main.activity_main.list
@ -93,12 +90,17 @@ class MainActivity : DarkModeSwitchActivity() {
toolbar.run { toolbar.run {
inflateMenu(R.menu.menu_main) inflateMenu(R.menu.menu_main)
menu.findItem(R.id.dark_mode) menu.findItem(R.id.dark_mode)
.isChecked = isDarkMode() .apply {
if (getCurrentNightMode() == UNKNOWN) {
isChecked = isDarkMode()
} else {
isVisible = false
}
}
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.about -> AboutDialog.show(this@MainActivity) R.id.about -> AboutDialog.show(this@MainActivity)
R.id.dark_mode -> toggleDarkMode() R.id.dark_mode -> toggleDarkMode()
R.id.support_me -> supportMe()
} }
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
@ -144,20 +146,4 @@ class MainActivity : DarkModeSwitchActivity() {
viewSite(model) 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)
}
}
} }

View file

@ -17,6 +17,8 @@ package com.afollestad.nocknock.ui.viewsite
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.os.Bundle import android.os.Bundle
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
@ -25,13 +27,18 @@ import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.ui.DarkModeSwitchActivity import com.afollestad.nocknock.ui.DarkModeSwitchActivity
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.utilities.providers.IntentProvider import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat 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.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData 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.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility 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.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.headersLayout import kotlinx.android.synthetic.main.activity_viewsite.headersLayout
import kotlinx.android.synthetic.main.activity_viewsite.iconStatus import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
@ -45,20 +52,26 @@ import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearch
import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_viewsite.scrollView import kotlinx.android.synthetic.main.activity_viewsite.scrollView
import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateBrowse
import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateInput
import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult
import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck
import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
import kotlinx.android.synthetic.main.include_app_bar.toolbar import kotlinx.android.synthetic.main.include_app_bar.toolbar
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
class ViewSiteActivity : DarkModeSwitchActivity() { class ViewSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
internal val viewModel by viewModel<ViewSiteViewModel>() internal val viewModel by viewModel<ViewSiteViewModel>()
private lateinit var validationForm: Form
private val intentProvider by inject<IntentProvider>() private val intentProvider by inject<IntentProvider>()
private val statusUpdateReceiver by lazy { private val statusUpdateReceiver by lazy {
@ -71,17 +84,18 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_viewsite) setContentView(R.layout.activity_viewsite)
setupUi()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
// Populate view model with initial data // Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as Site val model = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(model) viewModel.setModel(model)
setupUi()
setupValidation()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
// Loading // Loading
loadingProgress.observe(this, viewModel.onIsLoading()) loadingProgress.observe(this, viewModel.onIsLoading())
@ -93,23 +107,17 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
// Name // Name
inputName.attachLiveData(this, viewModel.name) inputName.attachLiveData(this, viewModel.name)
viewModel.onNameError()
.toViewError(this, inputName)
// Tags // Tags
inputTags.attachLiveData(this, viewModel.tags) inputTags.attachLiveData(this, viewModel.tags)
// Url // Url
inputUrl.attachLiveData(this, viewModel.url) inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlError()
.toViewError(this, inputUrl)
viewModel.onUrlWarningVisibility() viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning) .toViewVisibility(this, textUrlWarning)
// Timeout // Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout) responseTimeoutInput.attachLiveData(this, viewModel.timeout)
viewModel.onTimeoutError()
.toViewError(this, responseTimeoutInput)
// Validation mode // Validation mode
responseValidationMode.attachLiveData( responseValidationMode.attachLiveData(
@ -118,8 +126,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
outTransformer = { ValidationMode.fromIndex(it) }, outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() } inTransformer = { it.toIndex() }
) )
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription() viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription) .toViewText(this, validationModeDescription)
@ -128,25 +134,10 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
viewModel.onValidationSearchTermVisibility() viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm) .toViewVisibility(this, responseValidationSearchTerm)
// Validation script // SSL certificate
scriptInputLayout.attach( sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
codeData = viewModel.validationScript, viewModel.certificateUri.distinct()
errorData = viewModel.onValidationScriptError(), .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
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
)
// Headers // Headers
headersLayout.attach(viewModel.headers) headersLayout.attach(viewModel.headers)
@ -173,7 +164,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
setOnMenuItemClickListener { setOnMenuItemClickListener {
when (it.itemId) { when (it.itemId) {
R.id.commit -> viewModel.commit { finish() }
R.id.remove -> maybeRemoveSite() R.id.remove -> maybeRemoveSite()
R.id.disableChecks -> maybeDisableChecks() R.id.disableChecks -> maybeDisableChecks()
} }
@ -204,12 +194,91 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
.isVisible = it .isVisible = it
}) })
// Done button // Done item text
viewModel.onDoneButtonText() viewModel.onDoneButtonText()
.observe(this, Observer { .observe(this, Observer {
toolbar.menu.findItem(R.id.commit) toolbar.menu.findItem(R.id.commit)
.setTitle(it) .setTitle(it)
}) })
// SSL certificate
sslCertificateBrowse.setOnClickListener {
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
addCategory(CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
}
}
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) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
}
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {

View file

@ -23,9 +23,9 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.R import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.deleteSite import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Header 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.Site
import com.afollestad.nocknock.data.model.Status import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.Status.WAITING import com.afollestad.nocknock.data.model.Status.WAITING
@ -43,7 +43,6 @@ import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.utilities.livedata.map import com.afollestad.nocknock.utilities.livedata.map
import com.afollestad.nocknock.utilities.livedata.zip import com.afollestad.nocknock.utilities.livedata.zip
import com.afollestad.nocknock.utilities.providers.StringProvider import com.afollestad.nocknock.utilities.providers.StringProvider
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -76,25 +75,14 @@ class ViewSiteViewModel(
val retryPolicyTimes = MutableLiveData<Int>() val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>() val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>() val headers = MutableLiveData<List<Header>>()
val certificateUri = MutableLiveData<String>()
internal val disabled = MutableLiveData<Boolean>() internal val disabled = MutableLiveData<Boolean>()
internal val lastResult = MutableLiveData<ValidationResult?>() internal val lastResult = MutableLiveData<ValidationResult?>()
// Private properties
private val isLoading = MutableLiveData<Boolean>() 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 onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onNameError(): LiveData<Int?> = nameError
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> { @CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map { return url.map {
val parsed = HttpUrl.parse(it) val parsed = HttpUrl.parse(it)
@ -102,8 +90,6 @@ class ViewSiteViewModel(
} }
} }
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
@CheckResult fun onValidationModeDescription(): LiveData<Int> { @CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map { return validationMode.map {
when (it!!) { when (it!!) {
@ -114,20 +100,11 @@ class ViewSiteViewModel(
} }
} }
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationSearchTermVisibility() = @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError @CheckResult fun onDisableChecksVisibility(): LiveData<Boolean> = disabled.map { !it }
@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> = @CheckResult fun onDoneButtonText(): LiveData<Int> =
disabled.map { disabled.map {
@ -245,78 +222,16 @@ class ViewSiteViewModel(
} }
private fun getUpdatedDbModel(): Site? { 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 val timeout = timeout.value ?: 10_000
if (timeout < 0) { val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: ""
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
}
if (errorCount > 0) {
return null
}
val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
val newSettings = site.settings!!.copy( val newSettings = site.settings!!.copy(
validationIntervalMs = getCheckIntervalMs(), validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!, validationMode = validationMode.value!!,
validationArgs = getValidationArgs(), validationArgs = getValidationArgs(),
networkTimeout = timeout, networkTimeout = timeout,
disabled = false disabled = false,
certificate = certificateUri.value?.toString()
) )
val retryPolicyTimes = retryPolicyTimes.value ?: 0 val retryPolicyTimes = retryPolicyTimes.value ?: 0
@ -324,11 +239,15 @@ class ViewSiteViewModel(
val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
if (site.retryPolicy != null) { if (site.retryPolicy != null) {
// Have existing policy, update it // Have existing policy, update it
site.retryPolicy!!.copy(count = retryPolicyTimes, minutes = retryPolicyMinutes) site.retryPolicy!!.copy(
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
} else { } else {
// Create new policy // Create new policy
RetryPolicy( RetryPolicy(
count = retryPolicyTimes, minutes = retryPolicyMinutes count = retryPolicyTimes,
minutes = retryPolicyMinutes
) )
} }
} else { } else {

View file

@ -55,6 +55,11 @@ fun ViewSiteViewModel.setModel(site: Site) {
setCheckInterval(settings.validationIntervalMs) setCheckInterval(settings.validationIntervalMs)
setRetryPolicy(site.retryPolicy) setRetryPolicy(site.retryPolicy)
headers.value = site.headers headers.value = site.headers
if (settings.certificate == "null") {
certificateUri.value = ""
} else {
certificateUri.value = settings.certificate
}
this.disabled.value = settings.disabled this.disabled.value = settings.disabled
this.lastResult.value = site.lastResult this.lastResult.value = site.lastResult

View file

@ -91,20 +91,6 @@
android:layout_marginTop="@dimen/content_inset" android:layout_marginTop="@dimen/content_inset"
/> />
<TextView
android:text="@string/response_timeout"
style="@style/InputForm.Header"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<TextView <TextView
android:id="@+id/responseValidationLabel" android:id="@+id/responseValidationLabel"
android:text="@string/response_validation_mode" android:text="@string/response_validation_mode"
@ -137,7 +123,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset" android:layout_marginBottom="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_half" android:layout_marginTop="@dimen/content_inset_half"
android:background="@color/lighterGray" android:background="?scriptLayoutBackground"
/> />
<TextView <TextView
@ -159,6 +145,59 @@
android:layout_marginTop="@dimen/content_inset_more" android:layout_marginTop="@dimen/content_inset_more"
/> />
<TextView
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_timeout"
style="@style/InputForm.Header"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/ssl_certificate"
style="@style/NockText.SectionHeader"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
<EditText
android:id="@+id/sslCertificateInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:hint="@string/ssl_certificate_automatic"
android:inputType="textUri"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<Button
android:id="@+id/sslCertificateBrowse"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:text="@string/ssl_certificate_browse"
style="@style/AccentTextButton"
/>
</LinearLayout>
<include layout="@layout/include_divider"/> <include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout <com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout

View file

@ -26,7 +26,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingBottom="@dimen/content_inset" android:paddingBottom="@dimen/content_inset_double"
android:paddingLeft="@dimen/content_inset" android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset" android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_less" android:paddingTop="@dimen/content_inset_less"
@ -126,35 +126,6 @@
android:layout_marginTop="@dimen/content_inset" android:layout_marginTop="@dimen/content_inset"
/> />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_less"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:layout_marginTop="@dimen/content_inset_quarter"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
android:background="?dividerColor"
/>
<TextView <TextView
android:id="@+id/responseValidationLabel" android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -190,7 +161,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset" android:layout_marginBottom="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_half" android:layout_marginTop="@dimen/content_inset_half"
android:background="@color/lighterGray" android:background="?scriptLayoutBackground"
/> />
<TextView <TextView
@ -217,6 +188,66 @@
android:layout_marginTop="@dimen/content_inset" android:layout_marginTop="@dimen/content_inset"
/> />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:layout_marginTop="@dimen/content_inset_quarter"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/ssl_certificate"
style="@style/NockText.SectionHeader"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
<EditText
android:id="@+id/sslCertificateInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:hint="@string/ssl_certificate_automatic"
android:inputType="textUri"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<Button
android:id="@+id/sslCertificateBrowse"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:text="@string/ssl_certificate_browse"
style="@style/AccentTextButton"
/>
</LinearLayout>
<include layout="@layout/include_divider"/> <include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout <com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout

View file

@ -7,7 +7,4 @@
android:id="@+id/dark_mode" android:id="@+id/dark_mode"
android:checkable="true" android:checkable="true"
android:title="@string/dark_mode"/> android:title="@string/dark_mode"/>
<item
android:id="@+id/support_me"
android:title="@string/support_me"/>
</menu> </menu>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -13,10 +13,4 @@
<item>JavaScript Evaluation</item> <item>JavaScript Evaluation</item>
</string-array> </string-array>
<string-array name="donation_options">
<item>via PayPal</item>
<item>via Cash App</item>
<item>via Venmo</item>
</string-array>
</resources> </resources>

View file

@ -4,5 +4,6 @@
<attr format="color" name="toolbarTitleColor"/> <attr format="color" name="toolbarTitleColor"/>
<attr format="color" name="dividerColor"/> <attr format="color" name="dividerColor"/>
<attr format="color" name="iconColor"/> <attr format="color" name="iconColor"/>
<attr format="color" name="scriptLayoutBackground"/>
</resources> </resources>

View file

@ -7,7 +7,9 @@
<color name="colorPrimary_darkTheme">#212121</color> <color name="colorPrimary_darkTheme">#212121</color>
<color name="colorPrimaryDark_darkTheme">#252525</color> <color name="colorPrimaryDark_darkTheme">#252525</color>
<color name="lighterGray">#303030</color> <color name="darkerGray">#303030</color>
<color name="lighterGray">#EEEEEE</color>
<color name="colorAccent">#FF6E40</color> <color name="colorAccent">#FF6E40</color>
<color name="colorAccent_pressed">#E44615</color> <color name="colorAccent_pressed">#E44615</color>
<color name="colorAccent_translucent">#40FF6E40</color> <color name="colorAccent_translucent">#40FF6E40</color>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#758F9A</color>
</resources>

View file

@ -14,6 +14,7 @@
<a href=\'https://www.linkedin.com/in/afollestad\'>LinkedIn</a> <a href=\'https://www.linkedin.com/in/afollestad\'>LinkedIn</a>
<br/><br/><i>Nock Nock is open source! Check out the <a href=\'https://github.com/afollestad/nock-nock\'>GitHub page</a>!</i> <br/><br/><i>Nock Nock is open source! Check out the <a href=\'https://github.com/afollestad/nock-nock\'>GitHub page</a>!</i>
<br/>Icon by <a href=\'https://plus.google.com/+KevinAguilarC\'>Kevin Aguilar</a> of <b>221 Pixels</b>. <br/>Icon by <a href=\'https://plus.google.com/+KevinAguilarC\'>Kevin Aguilar</a> of <b>221 Pixels</b>.
<br/>View the <a href=\'https://af.codes/privacypolicies/nocknock.html\'>Privacy Policy</a>.
]]></string> ]]></string>
<string name="dark_mode">Dark Mode</string> <string name="dark_mode">Dark Mode</string>
@ -29,10 +30,9 @@
<string name="please_enter_name">Please enter a name!</string> <string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</string> <string name="please_enter_url">Please enter a URL.</string>
<string name="please_enter_valid_url">Please enter a valid URL.</string> <string name="please_enter_valid_url">Please enter a valid URL.</string>
<string name="please_enter_check_interval">Please input a validation interval.</string>
<string name="please_enter_search_term">Please input a search term.</string> <string name="please_enter_search_term">Please input a search term.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
<string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string> <string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string>
<string name="please_enter_validCertUri">Certificate should be a valid file or content URI.</string>
<string name="options">Options</string> <string name="options">Options</string>
<string name="remove_site">Remove Site</string> <string name="remove_site">Remove Site</string>
@ -59,6 +59,10 @@
<string name="response_timeout">Network Response Timeout (ms)</string> <string name="response_timeout">Network Response Timeout (ms)</string>
<string name="response_timeout_default">10000</string> <string name="response_timeout_default">10000</string>
<string name="ssl_certificate">SSL Certificate</string>
<string name="ssl_certificate_automatic">(Automatic)</string>
<string name="ssl_certificate_browse">Browse</string>
<string name="refresh_status">Refresh Status</string> <string name="refresh_status">Refresh Status</string>
<string name="warning_http_url"> <string name="warning_http_url">
@ -81,14 +85,6 @@
exception to pass custom error messages to Nock Nock. exception to pass custom error messages to Nock Nock.
</string> </string>
<string name="support_me">Donate</string>
<string name="support_me_message"><![CDATA[
<b>Nock Nock</b> was created and is maintained by one person. Donations are <b>much</b>
appreciated and encourage continued support.
]]></string>
<string name="thank_you">Thank you very much!</string>
<string name="next">Next</string>
<string name="install_web_browser">Please install a web browser app, such as Google Chrome.</string> <string name="install_web_browser">Please install a web browser app, such as Google Chrome.</string>
</resources> </resources>

View file

@ -4,19 +4,9 @@
<style name="AppTheme.Dark" parent="AppThemeParent.Dark"/> <style name="AppTheme.Dark" parent="AppThemeParent.Dark"/>
<style name="AccentButton" parent="Widget.MaterialComponents.Button">
<item name="android:textColor">#fff</item>
<item name="backgroundTint">@color/colorAccent</item>
<item name="android:fontFamily">@font/lato</item>
</style>
<style name="PrimaryDarkButton" parent="Widget.MaterialComponents.Button"> <style name="PrimaryDarkButton" parent="Widget.MaterialComponents.Button">
<item name="android:textColor">#fff</item> <item name="android:textColor">#fff</item>
<item name="backgroundTint">@color/lighterGray</item> <item name="backgroundTint">@color/darkerGray</item>
<item name="android:fontFamily">@font/lato</item>
</style>
<style name="AccentTextButton" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:fontFamily">@font/lato</item> <item name="android:fontFamily">@font/lato</item>
</style> </style>

View file

@ -9,6 +9,7 @@
<item name="toolbarTitleColor">#000000</item> <item name="toolbarTitleColor">#000000</item>
<item name="dividerColor">#EEEEEE</item> <item name="dividerColor">#EEEEEE</item>
<item name="iconColor">#000000</item> <item name="iconColor">#000000</item>
<item name="scriptLayoutBackground">@color/lighterGray</item>
<item name="android:textColorPrimary">#212121</item> <item name="android:textColorPrimary">#212121</item>
<item name="android:textColorSecondary">#727272</item> <item name="android:textColorSecondary">#727272</item>
@ -33,6 +34,7 @@
<item name="toolbarTitleColor">#ffffff</item> <item name="toolbarTitleColor">#ffffff</item>
<item name="dividerColor">#303030</item> <item name="dividerColor">#303030</item>
<item name="iconColor">#FFFFFF</item> <item name="iconColor">#FFFFFF</item>
<item name="scriptLayoutBackground">@color/darkerGray</item>
<item name="android:textColorPrimary">#FFFFFF</item> <item name="android:textColorPrimary">#FFFFFF</item>
<item name="android:textColorSecondary">#F0F0F0</item> <item name="android:textColorSecondary">#F0F0F0</item>

View file

@ -19,12 +19,13 @@ import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import com.afollestad.nocknock.data.AppDatabase import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.RetryPolicy import com.afollestad.nocknock.data.HeaderDao
import com.afollestad.nocknock.data.RetryPolicyDao import com.afollestad.nocknock.data.RetryPolicyDao
import com.afollestad.nocknock.data.SiteDao import com.afollestad.nocknock.data.SiteDao
import com.afollestad.nocknock.data.SiteSettingsDao import com.afollestad.nocknock.data.SiteSettingsDao
import com.afollestad.nocknock.data.ValidationResultsDao import com.afollestad.nocknock.data.ValidationResultsDao
import com.afollestad.nocknock.data.model.Header 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.Site
import com.afollestad.nocknock.data.model.SiteSettings import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status import com.afollestad.nocknock.data.model.Status
@ -56,7 +57,8 @@ fun fakeSettingsModel(
validationMode = validationMode, validationMode = validationMode,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000 networkTimeout = 10000,
certificate = null
) )
fun fakeResultModel( fun fakeResultModel(
@ -87,20 +89,24 @@ fun fakeHeaders(siteId: Long): List<Header> {
) )
} }
fun fakeModel(id: Long) = Site( fun fakeModel(
id: Long,
tags: String = ""
) = Site(
id = id, id = id,
name = "Test", name = "Test",
url = "https://test.com", url = "https://test.com",
tags = "", tags = tags,
settings = fakeSettingsModel(id), settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id), lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id), retryPolicy = fakeRetryPolicy(id),
headers = fakeHeaders(id) headers = fakeHeaders(id)
) )
val MOCK_MODEL_1 = fakeModel(1) val MOCK_MODEL_1 = fakeModel(1, tags = "one,two")
val MOCK_MODEL_2 = fakeModel(2) val MOCK_MODEL_2 = fakeModel(2, tags = "three,four")
val MOCK_MODEL_3 = fakeModel(3) val MOCK_MODEL_3 = fakeModel(3, tags = "five,six")
val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3) val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3)
fun mockDatabase(): AppDatabase { fun mockDatabase(): AppDatabase {
@ -165,12 +171,29 @@ fun mockDatabase(): AppDatabase {
on { update(isA()) } doReturn 1 on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1 on { delete(isA()) } doReturn 1
} }
val headerDao = mock<HeaderDao> {
on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> MOCK_MODEL_1.headers
2L -> MOCK_MODEL_2.headers
3L -> MOCK_MODEL_3.headers
else -> listOf()
}
}
on { insert(isA<Header>()) } doReturn 1L
on { insert(isA<List<Header>>()) } doReturn listOf(1L, 2L)
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
return mock { return mock {
on { siteDao() } doReturn siteDao on { siteDao() } doReturn siteDao
on { siteSettingsDao() } doReturn settingsDao on { siteSettingsDao() } doReturn settingsDao
on { validationResultsDao() } doReturn resultsDao on { validationResultsDao() } doReturn resultsDao
on { retryPolicyDao() } doReturn retryDao on { retryPolicyDao() } doReturn retryDao
on { headerDao() } doReturn headerDao
} }
} }

View file

@ -17,20 +17,21 @@ package com.afollestad.nocknock.ui.addsite
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.afollestad.nocknock.R 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.Site
import com.afollestad.nocknock.data.model.SiteSettings 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.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH 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.engine.validation.ValidationExecutor
import com.afollestad.nocknock.mockDatabase import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.utilities.ext.MINUTE import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.livedata.test import com.afollestad.nocknock.utilities.livedata.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argumentCaptor import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verify
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -149,247 +150,9 @@ class AddSiteViewModelTest {
assertThat(viewModel.getValidationArgs()).isEqualTo("Two") 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 { @Test fun commit_success() = runBlocking {
val isLoading = viewModel.onIsLoading() val isLoading = viewModel.onIsLoading()
.test() .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() fillInModel()
val onDone = mock<() -> Unit>() val onDone = mock<() -> Unit>()
@ -397,31 +160,30 @@ class AddSiteViewModelTest {
val siteCaptor = argumentCaptor<Site>() val siteCaptor = argumentCaptor<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>() val settingsCaptor = argumentCaptor<SiteSettings>()
val validationResultCaptor = argumentCaptor<ValidationResult>()
isLoading.assertValues(true, false) isLoading.assertValues(true, false)
verify(database.siteDao()).insert(siteCaptor.capture()) verify(database.siteDao()).insert(siteCaptor.capture())
verify(database.siteSettingsDao()).insert(settingsCaptor.capture()) verify(database.siteSettingsDao()).insert(settingsCaptor.capture())
verify(database.validationResultsDao(), never()).insert(any()) verify(database.validationResultsDao()).insert(validationResultCaptor.capture())
val settings = settingsCaptor.firstValue val settings = settingsCaptor.firstValue
val result = validationResultCaptor.firstValue.copy(siteId = 1)
val model = siteCaptor.firstValue.copy( val model = siteCaptor.firstValue.copy(
id = 1, // fill it in because our insert captor doesn't catch this id = 1, // fill it in because our insert captor doesn't catch this
settings = settings, settings = settings,
lastResult = null lastResult = result
) )
assertThat(result.reason).isNull()
assertThat(result.status).isEqualTo(WAITING)
verify(validationManager).scheduleValidation( verify(validationManager).scheduleValidation(
site = model, site = model,
rightNow = true, rightNow = true,
cancelPrevious = true, cancelPrevious = true,
fromFinishingJob = false fromFinishingJob = false
) )
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone).invoke() verify(onDone).invoke()
} }
@ -435,5 +197,10 @@ class AddSiteViewModelTest {
validationScript.value = null validationScript.value = null
checkIntervalValue.value = 60 checkIntervalValue.value = 60
checkIntervalUnit.value = 1000 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")
)
} }
} }

View file

@ -60,18 +60,45 @@ class MainViewModelTest {
.test() .test()
val sites = viewModel.onSites() val sites = viewModel.onSites()
.test() .test()
val tags = viewModel.onTags()
.test()
val tagsVisibility = viewModel.onTagsListVisibility()
.test()
viewModel.onResume() viewModel.onResume()
verify(notificationManager).cancelStatusNotifications() verify(notificationManager).cancelStatusNotifications()
verify(validationManager).ensureScheduledValidations() verify(validationManager).ensureScheduledValidations()
sites.assertValues( sites.assertValues(ALL_MOCK_MODELS)
listOf(),
ALL_MOCK_MODELS
)
isLoading.assertValues(true, false) isLoading.assertValues(true, false)
emptyTextVisibility.assertValues(false, false) emptyTextVisibility.assertValues(false, false)
tags.assertValues(listOf("one", "two", "three", "four", "five", "six").sorted())
tagsVisibility.assertValues(true)
}
@Test fun onTagSelection() = runBlocking {
val isLoading = viewModel.onIsLoading()
.test()
val emptyTextVisibility = viewModel.onEmptyTextVisibility()
.test()
val sites = viewModel.onSites()
.test()
val tags = viewModel.onTags()
.test()
val tagsVisibility = viewModel.onTagsListVisibility()
.test()
viewModel.onTagSelection(listOf("four", "six"))
verify(notificationManager).cancelStatusNotifications()
verify(validationManager).ensureScheduledValidations()
sites.assertValues(listOf(MOCK_MODEL_2, MOCK_MODEL_3))
isLoading.assertValues(true, false)
emptyTextVisibility.assertValues(false, false)
tags.assertValues(listOf("one", "two", "three", "four", "five", "six").sorted())
tagsVisibility.assertValues(true)
} }
@Test fun postSiteUpdate_notFound() { @Test fun postSiteUpdate_notFound() {
@ -86,10 +113,7 @@ class MainViewModelTest {
.test() .test()
viewModel.onResume() viewModel.onResume()
sites.assertValues( sites.assertValues(ALL_MOCK_MODELS)
listOf(),
ALL_MOCK_MODELS
)
val updatedModel2 = MOCK_MODEL_2.copy( val updatedModel2 = MOCK_MODEL_2.copy(
name = "Wakanda Forever!!!" name = "Wakanda Forever!!!"
@ -120,10 +144,7 @@ class MainViewModelTest {
.test() .test()
viewModel.onResume() viewModel.onResume()
sites.assertValues( sites.assertValues(ALL_MOCK_MODELS)
listOf(),
ALL_MOCK_MODELS
)
isLoading.assertValues(true, false) isLoading.assertValues(true, false)
val modifiedModel = MOCK_MODEL_1.copy(id = 11111) val modifiedModel = MOCK_MODEL_1.copy(id = 11111)
@ -147,10 +168,7 @@ class MainViewModelTest {
.test() .test()
viewModel.onResume() viewModel.onResume()
sites.assertValues( sites.assertValues(ALL_MOCK_MODELS)
listOf(),
ALL_MOCK_MODELS
)
isLoading.assertValues(true, false) isLoading.assertValues(true, false)
val modelsWithout1 = ALL_MOCK_MODELS.toMutableList() val modelsWithout1 = ALL_MOCK_MODELS.toMutableList()

View file

@ -18,6 +18,8 @@ package com.afollestad.nocknock.ui.viewsite
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.afollestad.nocknock.MOCK_MODEL_1 import com.afollestad.nocknock.MOCK_MODEL_1
import com.afollestad.nocknock.R 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.Site
import com.afollestad.nocknock.data.model.SiteSettings import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status.CHECKING 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.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.engine.validation.ValidationExecutor import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.fakeRetryPolicy
import com.afollestad.nocknock.mockDatabase import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.livedata.test 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.any
import com.nhaarman.mockitokotlin2.argumentCaptor import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doAnswer import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -255,247 +259,11 @@ class ViewSiteViewModelTest {
.isEqualTo("Two") .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 { @Test fun commit_success() = runBlocking {
whenever(database.retryPolicyDao().forSite(any())).doReturn(listOf(fakeRetryPolicy(1)))
val isLoading = viewModel.onIsLoading() val isLoading = viewModel.onIsLoading()
.test() .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() fillInModel()
val onDone = mock<() -> Unit>() val onDone = mock<() -> Unit>()
@ -506,11 +274,13 @@ class ViewSiteViewModelTest {
val siteCaptor = argumentCaptor<Site>() val siteCaptor = argumentCaptor<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>() val settingsCaptor = argumentCaptor<SiteSettings>()
val resultCaptor = argumentCaptor<ValidationResult>() val resultCaptor = argumentCaptor<ValidationResult>()
val retryPolicyCaptor = argumentCaptor<RetryPolicy>()
isLoading.assertValues(true, false) isLoading.assertValues(true, false)
verify(database.siteDao()).update(siteCaptor.capture()) verify(database.siteDao()).update(siteCaptor.capture())
verify(database.siteSettingsDao()).update(settingsCaptor.capture()) verify(database.siteSettingsDao()).update(settingsCaptor.capture())
verify(database.validationResultsDao()).update(resultCaptor.capture()) verify(database.validationResultsDao()).update(resultCaptor.capture())
verify(database.retryPolicyDao()).update(retryPolicyCaptor.capture())
// From fillInModel() below // From fillInModel() below
val updatedSettings = MOCK_MODEL_1.settings!!.copy( val updatedSettings = MOCK_MODEL_1.settings!!.copy(
@ -523,11 +293,13 @@ class ViewSiteViewModelTest {
val updatedResult = MOCK_MODEL_1.lastResult!!.copy( val updatedResult = MOCK_MODEL_1.lastResult!!.copy(
status = WAITING status = WAITING
) )
val retryPolicy = retryPolicyCaptor.firstValue
val updatedModel = MOCK_MODEL_1.copy( val updatedModel = MOCK_MODEL_1.copy(
name = "Hello There", name = "Hello There",
url = "https://www.hellothere.com", url = "https://www.hellothere.com",
settings = updatedSettings, settings = updatedSettings,
lastResult = updatedResult lastResult = updatedResult,
retryPolicy = retryPolicy
) )
assertThat(siteCaptor.firstValue).isEqualTo(updatedModel) assertThat(siteCaptor.firstValue).isEqualTo(updatedModel)
@ -541,13 +313,6 @@ class ViewSiteViewModelTest {
fromFinishingJob = false fromFinishingJob = false
) )
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone).invoke() verify(onDone).invoke()
} }
@ -619,5 +384,12 @@ class ViewSiteViewModelTest {
validationScript.value = "throw 'Oh no!'" validationScript.value = "throw 'Oh no!'"
checkIntervalValue.value = 24 checkIntervalValue.value = 24
checkIntervalUnit.value = 60000 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")
)
} }
} }

BIN
art/showcase5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

View file

@ -15,6 +15,7 @@ buildscript {
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin
classpath 'com.github.ben-manes:gradle-versions-plugin:' + versions.versionPlugin classpath 'com.github.ben-manes:gradle-versions-plugin:' + versions.versionPlugin
classpath 'io.fabric.tools:gradle:' + versions.fabricPlugin classpath 'io.fabric.tools:gradle:' + versions.fabricPlugin
classpath 'com.google.gms:google-services:' + versions.googleServices
} }
} }
@ -22,7 +23,6 @@ allprojects {
repositories { repositories {
google() google()
jcenter() jcenter()
maven { url "https://dl.bintray.com/drummer-aidan/maven" }
maven { url "https://jitpack.io" } maven { url "https://jitpack.io" }
} }

View file

@ -12,6 +12,10 @@ android {
versionName versions.publishVersion versionName versions.publishVersion
} }
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
// For Mozilla Rhino // For Mozilla Rhino
lintOptions { lintOptions {
abortOnError false abortOnError false
@ -30,6 +34,7 @@ dependencies {
implementation 'org.mozilla:rhino:' + versions.rhino implementation 'org.mozilla:rhino:' + versions.rhino
api 'com.afollestad:rxkprefs:' + versions.rxkPrefs api 'com.afollestad:rxkprefs:' + versions.rxkPrefs
api "io.reactivex.rxjava2:rxjava:" + versions.rxJava
testImplementation 'junit:junit:' + versions.junit testImplementation 'junit:junit:' + versions.junit
testImplementation 'com.google.truth:truth:' + versions.truth testImplementation 'com.google.truth:truth:' + versions.truth

View file

@ -0,0 +1,27 @@
/**
* 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.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()
}

View file

@ -21,7 +21,12 @@ import android.widget.EditText
import androidx.annotation.IntRange import androidx.annotation.IntRange
import kotlin.math.min import kotlin.math.min
fun EditText.setTextAndMaintainSelection(text: CharSequence) { fun EditText.setTextAndMaintainSelection(text: CharSequence?) {
if (text == null) {
setText("")
return
}
val formerStart = min(selectionStart, text.length) val formerStart = min(selectionStart, text.length)
val formerEnd = min(selectionEnd, text.length) val formerEnd = min(selectionEnd, text.length)
setText(text) setText(text)

View file

@ -20,6 +20,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.BigTextStyle
import androidx.core.app.NotificationCompat.DEFAULT_VIBRATE import androidx.core.app.NotificationCompat.DEFAULT_VIBRATE
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
@ -56,6 +57,10 @@ class RealNotificationProvider(
.setLargeIcon(largeIcon) .setLargeIcon(largeIcon)
.setAutoCancel(true) .setAutoCancel(true)
.setDefaults(DEFAULT_VIBRATE) .setDefaults(DEFAULT_VIBRATE)
.setStyle(
BigTextStyle()
.bigText(content)
)
.build() .build()
} }
} }

View file

@ -14,6 +14,10 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
} }
dependencies { dependencies {

View file

@ -181,7 +181,8 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE, validationMode = STATUS_CODE,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000 networkTimeout = 10000,
certificate = null
) )
val newId = settingsDao.insert(model) val newId = settingsDao.insert(model)
assertThat(newId).isEqualTo(1) assertThat(newId).isEqualTo(1)
@ -199,7 +200,8 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE, validationMode = STATUS_CODE,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000 networkTimeout = 10000,
certificate = null
) )
) )
@ -227,7 +229,8 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE, validationMode = STATUS_CODE,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000 networkTimeout = 10000,
certificate = null
) )
) )
@ -428,9 +431,30 @@ class AppDatabaseTest() {
val allSites = db.allSites() val allSites = db.allSites()
assertThat(allSites.size).isEqualTo(3) assertThat(allSites.size).isEqualTo(3)
assertThat(allSites[0]).isEqualTo(MOCK_MODEL_1) assertThat(allSites[0]).isEqualTo(
assertThat(allSites[1]).isEqualTo(MOCK_MODEL_2) MOCK_MODEL_1.copy(
assertThat(allSites[2]).isEqualTo(MOCK_MODEL_3) 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() { @Test fun extension_put_getSite() {
@ -467,10 +491,12 @@ class AppDatabaseTest() {
) )
val updatedHeaders = listOf( val updatedHeaders = listOf(
modelToUpdate.headers.first().copy( modelToUpdate.headers.first().copy(
id = 7,
key = "One", key = "One",
value = "Hello" value = "Hello"
), ),
modelToUpdate.headers.last().copy( modelToUpdate.headers.last().copy(
id = 8,
key = "Two", key = "Two",
value = "Hey" value = "Hey"
) )

View file

@ -35,7 +35,8 @@ fun fakeSettingsModel(
validationMode = validationMode, validationMode = validationMode,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000 networkTimeout = 10000,
certificate = null
) )
fun fakeResultModel( fun fakeResultModel(

View file

@ -34,7 +34,7 @@ import com.afollestad.nocknock.data.model.ValidationResult
SiteSettings::class, SiteSettings::class,
Site::class Site::class
], ],
version = 4, version = 5,
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)

View file

@ -57,3 +57,15 @@ class Database3to4Migration : Migration(3, 4) {
) )
} }
} }
/**
* Migrates the database from version 4 to 5.
*
* @author Aidan Follestad (@afollestad)
*/
class Database4to5Migration : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `site_settings` ADD COLUMN certificate TEXT")
}
}

View file

@ -40,8 +40,10 @@ data class SiteSettings(
/** Whether or not the [Site] is enabled for automatic periodic checks. */ /** Whether or not the [Site] is enabled for automatic periodic checks. */
var disabled: Boolean, var disabled: Boolean,
/** The network response timeout for validation attempts. */ /** The network response timeout for validation attempts. */
var networkTimeout: Int var networkTimeout: Int,
/** The Uri to a self signed certificate. */
var certificate: String?
) : Serializable { ) : Serializable {
constructor() : this(0, 0, STATUS_CODE, null, false, 0) constructor() : this(0, 0, STATUS_CODE, null, false, 0, null)
} }

View file

@ -3,52 +3,56 @@ ext.versions = [
minSdk : 21, minSdk : 21,
compileSdk : 28, compileSdk : 28,
buildTools : '28.0.3', buildTools : '28.0.3',
publishVersion : '0.8.4', publishVersion : '0.8.8',
publishVersionCode : 37, publishVersionCode : 46,
// Plugins // Plugins
gradlePlugin : '3.2.1', gradlePlugin : '3.4.0',
spotlessPlugin : '3.17.0', spotlessPlugin : '3.22.0',
versionPlugin : '0.20.0', versionPlugin : '0.21.0',
googleServices : '4.2.0',
fabricPlugin : '1.+', fabricPlugin : '1.+',
// Misc // Misc
okHttp : '3.12.1', okHttp : '3.14.1',
rhino : '1.7.10', rhino : '1.7.10',
// Kotlin // Kotlin
kotlin : '1.3.11', kotlin : '1.3.30',
coroutines : '1.1.0', coroutines : '1.2.0',
koin : '1.0.2', koin : '1.0.2',
// Google/AndroidX // Google/AndroidX
androidxAnnotations : '1.0.1', androidxAnnotations : '1.0.2',
androidxCore : '1.0.2', androidxCore : '1.0.2',
androidxRecyclerView: '1.0.0', androidxRecyclerView: '1.0.0',
androidxBrowser : '1.0.0', androidxBrowser : '1.0.0',
googleMaterial : '1.0.0', googleMaterial : '1.0.0',
room : '2.0.0', room : '2.0.0',
lifecycle : '2.0.0', lifecycle : '2.0.0',
firebaseCore : '16.0.8',
// Rx // Rx
rxJava : '2.2.8',
rxBinding : '3.0.0-alpha1', rxBinding : '3.0.0-alpha1',
// afollestad // afollestad
materialDialogs : '2.0.0-rc7', materialDialogs : '2.8.1',
rxkPrefs : '1.2.1', rxkPrefs : '1.2.5',
vvalidator : '0.4.1',
// Debugging // Debugging
timber : '4.7.1', timber : '4.7.1',
fabric : '2.9.8@aar', fabric : '2.9.9@aar',
// Unit testing // Unit testing
junit : '4.12', junit : '4.12',
mockito : '2.23.4', mockito : '2.27.0',
mockitoKotlin : '2.0.0-RC1', mockitoKotlin : '2.1.0',
truth : '0.42', truth : '0.44',
// UI testing // UI testing
androidxTestRunner : '1.1.1', androidxTestRunner : '1.1.1',
androidxTest : '1.1.0', androidxTest : '1.1.0',
archTesting : '2.0.0' archTesting : '2.0.1'
] ]

View file

@ -15,6 +15,8 @@
*/ */
package com.afollestad.nocknock.engine package com.afollestad.nocknock.engine
import com.afollestad.nocknock.engine.ssl.RealSslManager
import com.afollestad.nocknock.engine.ssl.SslManager
import com.afollestad.nocknock.engine.validation.RealValidationExecutor import com.afollestad.nocknock.engine.validation.RealValidationExecutor
import com.afollestad.nocknock.engine.validation.ValidationExecutor import com.afollestad.nocknock.engine.validation.ValidationExecutor
import org.koin.dsl.module.module import org.koin.dsl.module.module
@ -23,6 +25,8 @@ import org.koin.dsl.module.module
val engineModule = module { val engineModule = module {
single { single {
RealValidationExecutor(get(), get(), get(), get(), get(), get()) RealValidationExecutor(get(), get(), get(), get(), get(), get(), get())
} bind ValidationExecutor::class } bind ValidationExecutor::class
factory { RealSslManager(get()) } bind SslManager::class
} }

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.engine.ssl
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.annotation.CheckResult
import com.afollestad.nocknock.utilities.ext.toUri
import okhttp3.OkHttpClient
import java.io.BufferedInputStream
import java.io.FileInputStream
import java.security.KeyStore
import java.security.cert.CertificateFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */
interface SslManager {
@CheckResult fun clientForCertificate(
certUri: String,
siteUri: String,
client: OkHttpClient
): OkHttpClient
}
/** @author Aidan Follestad (@afollestad) **/
class RealSslManager(
private val app: Application
) : SslManager {
override fun clientForCertificate(
certUri: String,
siteUri: String,
client: OkHttpClient
): OkHttpClient {
val parsedCertUri = certUri.toUri()
val parsedSiteUri = siteUri.toUri()
val siteHost = parsedSiteUri.host ?: ""
log("Loading certificate $certUri for host $siteHost")
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null)
val certInputStream = app.openUri(parsedCertUri)
val bis = BufferedInputStream(certInputStream)
val certificateFactory = CertificateFactory.getInstance("X.509")
while (bis.available() > 0) {
val cert = certificateFactory.generateCertificate(bis)
keyStore.setCertificateEntry(siteHost, cert)
}
val trustManagerFactory =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(keyStore)
val trustManagers = trustManagerFactory.trustManagers
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustManagers, null)
val trustManager = trustManagers.first() as X509TrustManager
log("Loaded successfully!")
return client.newBuilder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.hostnameVerifier { hostname, _ ->
log("Verifying hostname $hostname")
hostname == siteHost
}
.build()
}
}
private fun Context.openUri(uri: Uri) = when (uri.scheme) {
"content" -> {
contentResolver.openInputStream(uri) ?: throw IllegalStateException(
"Unable to open input stream to $uri"
)
}
"file" -> FileInputStream(uri.path)
else -> FileInputStream(uri.toString())
}

View file

@ -17,21 +17,26 @@ package com.afollestad.nocknock.engine.validation
import android.app.job.JobScheduler import android.app.job.JobScheduler
import android.app.job.JobScheduler.RESULT_SUCCESS import android.app.job.JobScheduler.RESULT_SUCCESS
import android.net.Uri
import com.afollestad.nocknock.data.AppDatabase import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.allSites import com.afollestad.nocknock.data.allSites
import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.ERROR import com.afollestad.nocknock.data.model.Status.ERROR
import com.afollestad.nocknock.data.model.Status.OK import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.engine.R 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.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.BundleProvider
import com.afollestad.nocknock.utilities.providers.JobInfoProvider import com.afollestad.nocknock.utilities.providers.JobInfoProvider
import com.afollestad.nocknock.utilities.providers.StringProvider import com.afollestad.nocknock.utilities.providers.StringProvider
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jetbrains.annotations.TestOnly
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.TimeUnit.MILLISECONDS
import kotlin.math.max
import timber.log.Timber.d as log import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
@ -42,6 +47,8 @@ data class CheckResult(
typealias ClientTimeoutChanger = (client: OkHttpClient, timeout: Int) -> OkHttpClient typealias ClientTimeoutChanger = (client: OkHttpClient, timeout: Int) -> OkHttpClient
typealias UriConverter = (String) -> Uri
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
interface ValidationExecutor { interface ValidationExecutor {
@ -66,7 +73,8 @@ class RealValidationExecutor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val bundleProvider: BundleProvider, private val bundleProvider: BundleProvider,
private val jobInfoProvider: JobInfoProvider, private val jobInfoProvider: JobInfoProvider,
private val database: AppDatabase private val database: AppDatabase,
private val sslManager: SslManager
) : ValidationExecutor { ) : ValidationExecutor {
private var clientTimeoutChanger: ClientTimeoutChanger = { client, timeout -> private var clientTimeoutChanger: ClientTimeoutChanger = { client, timeout ->
@ -147,21 +155,32 @@ class RealValidationExecutor(
check(site.id != 0L) { "Cannot schedule validations for jobs with no ID." } check(site.id != 0L) { "Cannot schedule validations for jobs with no ID." }
val siteSettings = site.settings val siteSettings = site.settings
requireNotNull(siteSettings) { "Site settings must be populated." } 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}") log("performValidation(${site.id}) - GET ${site.url}")
val request = Request.Builder() val request = Request.Builder()
.apply { .apply {
url(site.url) url(site.url)
get() get()
site.headers.forEach { header -> site.headers
.filter { header -> header.key.isNotNullOrEmpty() }
.forEach { header ->
addHeader(header.key, header.value) addHeader(header.key, header.value)
} }
} }
.build() .build()
return try { return try {
val client = clientTimeoutChanger(okHttpClient, siteSettings.networkTimeout) 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,
client = clientWithTimeout
)
} else {
clientWithTimeout
}
val response = client.newCall(request) val response = client.newCall(request)
.execute() .execute()
@ -190,6 +209,7 @@ class RealValidationExecutor(
) )
) )
} catch (ex: Exception) { } catch (ex: Exception) {
ex.printStackTrace()
log("performValidation(${site.id}) = Error: ${ex.message}") log("performValidation(${site.id}) = Error: ${ex.message}")
CheckResult(model = site.withStatus(status = ERROR, reason = ex.message)) CheckResult(model = site.withStatus(status = ERROR, reason = ex.message))
} }
@ -199,7 +219,7 @@ class RealValidationExecutor(
jobScheduler.allPendingJobs jobScheduler.allPendingJobs
.firstOrNull { job -> job.id == site.id.toInt() } .firstOrNull { job -> job.id == site.id.toInt() }
// @TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) { @TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) {
// this.clientTimeoutChanger = changer this.clientTimeoutChanger = changer
// } }
} }

View file

@ -0,0 +1,189 @@
/**
* 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.engine
import android.content.Intent
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.HeaderDao
import com.afollestad.nocknock.data.RetryPolicyDao
import com.afollestad.nocknock.data.SiteDao
import com.afollestad.nocknock.data.SiteSettingsDao
import com.afollestad.nocknock.data.ValidationResultsDao
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
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationResult
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
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
) = SiteSettings(
siteId = id,
validationIntervalMs = 600000,
validationMode = validationMode,
validationArgs = null,
disabled = false,
networkTimeout = 10000,
certificate = null
)
fun fakeResultModel(
id: Long,
status: Status = OK,
reason: String? = null
) = ValidationResult(
siteId = id,
status = status,
reason = reason,
timestampMs = currentTimeMillis()
)
fun fakeRetryPolicy(
id: Long,
count: Int = 3,
minutes: Int = 6
) = RetryPolicy(
siteId = id,
count = count,
minutes = minutes
)
fun fakeHeaders(siteId: Long): List<Header> {
return listOf(
Header(id = siteId + 1, siteId = siteId, key = "Content-Type", value = "text/html"),
Header(id = siteId + 2, siteId = siteId, key = "User-Agent", value = "NockNock")
)
}
fun fakeModel(id: Long) = Site(
id = id,
name = "Test",
url = "https://test.com",
tags = "",
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id),
headers = fakeHeaders(id)
)
val MOCK_MODEL_1 = fakeModel(1)
val MOCK_MODEL_2 = fakeModel(2)
val MOCK_MODEL_3 = fakeModel(3)
val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3)
fun mockDatabase(): AppDatabase {
val siteDao = mock<SiteDao> {
on { insert(isA()) } doReturn 1
on { one(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1)
2L -> listOf(MOCK_MODEL_2)
3L -> listOf(MOCK_MODEL_3)
else -> listOf()
}
}
on { all() } doReturn ALL_MOCK_MODELS
on { update(isA()) } doAnswer { inv ->
return@doAnswer inv.arguments.size
}
on { delete(isA()) } doAnswer { inv ->
return@doAnswer inv.arguments.size
}
}
val settingsDao = mock<SiteSettingsDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.settings!!)
2L -> listOf(MOCK_MODEL_2.settings!!)
3L -> listOf(MOCK_MODEL_3.settings!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val resultsDao = mock<ValidationResultsDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.lastResult!!)
2L -> listOf(MOCK_MODEL_2.lastResult!!)
3L -> listOf(MOCK_MODEL_3.lastResult!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val retryDao = mock<RetryPolicyDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.retryPolicy!!)
2L -> listOf(MOCK_MODEL_2.retryPolicy!!)
3L -> listOf(MOCK_MODEL_3.retryPolicy!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val headerDao = mock<HeaderDao> {
on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> MOCK_MODEL_1.headers
2L -> MOCK_MODEL_2.headers
3L -> MOCK_MODEL_3.headers
else -> listOf()
}
}
on { insert(isA<Header>()) } doReturn 1L
on { insert(isA<List<Header>>()) } doReturn listOf(1L, 2L)
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
return mock {
on { siteDao() } doReturn siteDao
on { siteSettingsDao() } doReturn settingsDao
on { validationResultsDao() } doReturn resultsDao
on { retryPolicyDao() } doReturn retryDao
on { headerDao() } doReturn headerDao
}
}

View file

@ -18,6 +18,8 @@ package com.afollestad.nocknock.engine
import android.app.job.JobInfo import android.app.job.JobInfo
import android.content.ComponentName import android.content.ComponentName
import android.os.PersistableBundle import android.os.PersistableBundle
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.utilities.providers.BundleProvider import com.afollestad.nocknock.utilities.providers.BundleProvider
import com.afollestad.nocknock.utilities.providers.IBundle import com.afollestad.nocknock.utilities.providers.IBundle
import com.afollestad.nocknock.utilities.providers.IBundler import com.afollestad.nocknock.utilities.providers.IBundler
@ -34,11 +36,11 @@ fun testBundleProvider(): BundleProvider {
val realBundle = mock<PersistableBundle>() val realBundle = mock<PersistableBundle>()
val creator = it.getArgument<IBundler>(0) val creator = it.getArgument<IBundler>(0)
creator(object : IBundle { creator(object : IBundle {
override fun putInt( override fun putLong(
key: String, key: String,
value: Int value: Long
) { ) {
whenever(realBundle.getInt(key)).doReturn(value) whenever(realBundle.getLong(key)).doReturn(value)
} }
}) })
return@doAnswer realBundle return@doAnswer realBundle
@ -66,3 +68,21 @@ fun testJobInfoProvider(): JobInfoProvider {
} }
return provider return provider
} }
fun AppDatabase.setAllSites(vararg sites: Site) {
whenever(siteDao().all()).doReturn(listOf(*sites))
for (site in sites) {
whenever(siteSettingsDao().forSite(site.id))
.doReturn(listOf(site.settings!!))
if (site.lastResult != null) {
whenever(validationResultsDao().forSite(site.id))
.doReturn(listOf(site.lastResult!!))
}
if (site.retryPolicy != null) {
whenever(retryPolicyDao().forSite(site.id))
.doReturn(listOf(site.retryPolicy!!))
}
whenever(headerDao().forSite(site.id))
.doReturn(site.headers)
}
}

View file

@ -17,13 +17,12 @@ package com.afollestad.nocknock.engine
import android.app.job.JobInfo import android.app.job.JobInfo
import android.app.job.JobScheduler import android.app.job.JobScheduler
import com.afollestad.nocknock.data.legacy.ServerModel import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.data.model.Status.ERROR import com.afollestad.nocknock.data.model.Status.ERROR
import com.afollestad.nocknock.data.model.Status.OK import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE import com.afollestad.nocknock.engine.ssl.SslManager
import com.afollestad.nocknock.data.legacy.ServerModelStore import com.afollestad.nocknock.engine.validation.RealValidationExecutor
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID 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.afollestad.nocknock.utilities.providers.StringProvider
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.any
@ -45,7 +44,7 @@ import okhttp3.ResponseBody
import org.junit.Test import org.junit.Test
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
class CheckStatusManagerTest { class ValidationExecutorTest {
private val timeoutError = "Oh no, a timeout" private val timeoutError = "Oh no, a timeout"
@ -56,15 +55,21 @@ class CheckStatusManagerTest {
} }
private val bundleProvider = testBundleProvider() private val bundleProvider = testBundleProvider()
private val jobInfoProvider = testJobInfoProvider() private val jobInfoProvider = testJobInfoProvider()
private val store = mock<ServerModelStore>() private val database = mockDatabase()
private val sslManager = mock<SslManager> {
on { clientForCertificate(any(), any(), any()) } doAnswer { inv ->
inv.getArgument<OkHttpClient>(2)
}
}
private val manager = RealValidationManager( private val manager = RealValidationExecutor(
jobScheduler, jobScheduler,
okHttpClient, okHttpClient,
stringProvider, stringProvider,
bundleProvider, bundleProvider,
jobInfoProvider, jobInfoProvider,
store database,
sslManager
).apply { ).apply {
setClientTimeoutChanger { _, timeout -> setClientTimeoutChanger { _, timeout ->
whenever(okHttpClient.callTimeoutMillis()).doReturn(timeout) whenever(okHttpClient.callTimeoutMillis()).doReturn(timeout)
@ -72,202 +77,241 @@ class CheckStatusManagerTest {
} }
} }
@Test fun ensureScheduledChecks_noEnabledSites() = runBlocking { @Test fun ensureScheduledValidations_noEnabledSites() = runBlocking {
val model1 = fakeModel().copy(disabled = true) val model1 = fakeModel(id = 1)
whenever(store.get()).doReturn(listOf(model1)) model1.settings = model1.settings!!.copy(disabled = true)
database.setAllSites(model1)
manager.ensureScheduledChecks() manager.ensureScheduledValidations()
verifyNoMoreInteractions(jobScheduler) verifyNoMoreInteractions(jobScheduler)
} }
@Test fun ensureScheduledChecks_sitesAlreadyHaveJobs() = runBlocking<Unit> { @Test fun ensureScheduledValidations_sitesAlreadyHaveJobs() = runBlocking<Unit> {
val model1 = fakeModel() val model1 = fakeModel(id = 1)
val job1 = fakeJob(model1.id) val job1 = fakeJob(1)
whenever(store.get()).doReturn(listOf(model1)) database.setAllSites(model1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1)) whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
manager.ensureScheduledChecks() manager.ensureScheduledValidations()
verify(jobScheduler, never()).schedule(any()) verify(jobScheduler, never()).schedule(any())
} }
@Test fun ensureScheduledChecks() = runBlocking { @Test fun ensureScheduledValidations() = runBlocking {
val model1 = fakeModel() val model1 = fakeModel(id = 1)
whenever(store.get()).doReturn(listOf(model1)) database.setAllSites(model1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>()) whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>())
manager.ensureScheduledChecks() manager.ensureScheduledValidations()
val jobCaptor = argumentCaptor<JobInfo>() val jobCaptor = argumentCaptor<JobInfo>()
verify(jobScheduler).schedule(jobCaptor.capture()) verify(jobScheduler).schedule(jobCaptor.capture())
val jobInfo = jobCaptor.allValues.single() val jobInfo = jobCaptor.allValues.single()
assertThat(jobInfo.id).isEqualTo(model1.id) assertThat(jobInfo.id).isEqualTo(model1.id)
assertThat(jobInfo.extras.getInt(KEY_SITE_ID)).isEqualTo(model1.id) assertThat(jobInfo.extras.getLong(KEY_SITE_ID)).isEqualTo(model1.id)
} }
@Test fun scheduleCheck_rightNow() { @Test fun scheduleValidation_rightNow() {
val model1 = fakeModel() val model1 = fakeModel(id = 1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>()) whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>())
manager.scheduleCheck( manager.scheduleValidation(
site = model1, site = model1,
rightNow = true rightNow = true
) )
val jobCaptor = argumentCaptor<JobInfo>() val jobCaptor = argumentCaptor<JobInfo>()
verify(jobScheduler).schedule(jobCaptor.capture()) verify(jobScheduler).schedule(jobCaptor.capture())
verify(jobScheduler).cancel(model1.id) verify(jobScheduler).cancel(1)
val jobInfo = jobCaptor.allValues.single() val jobInfo = jobCaptor.allValues.single()
assertThat(jobInfo.id).isEqualTo(model1.id) assertThat(jobInfo.id).isEqualTo(model1.id)
assertThat(jobInfo.extras.getInt(KEY_SITE_ID)).isEqualTo(model1.id) assertThat(jobInfo.extras.getLong(KEY_SITE_ID)).isEqualTo(model1.id)
} }
@Test(expected = IllegalStateException::class) @Test(expected = IllegalStateException::class)
fun scheduleCheck_notFromFinishingJob_haveExistingJob() { fun scheduleValidation_notFromFinishingJob_haveExistingJob() {
val model1 = fakeModel() val model1 = fakeModel(id = 1)
val job1 = fakeJob(model1.id) val job1 = fakeJob(1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1)) whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
manager.scheduleCheck( manager.scheduleValidation(
site = model1, site = model1,
fromFinishingJob = false fromFinishingJob = false
) )
} }
@Test fun scheduleCheck_fromFinishingJob_haveExistingJob() { @Test fun scheduleValidation_fromFinishingJob_haveExistingJob() {
val model1 = fakeModel() val model1 = fakeModel(id = 1)
val job1 = fakeJob(model1.id) val job1 = fakeJob(1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1)) whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
manager.scheduleCheck( manager.scheduleValidation(
site = model1, site = model1,
fromFinishingJob = true fromFinishingJob = true
) )
val jobCaptor = argumentCaptor<JobInfo>() val jobCaptor = argumentCaptor<JobInfo>()
verify(jobScheduler).schedule(jobCaptor.capture()) verify(jobScheduler).schedule(jobCaptor.capture())
verify(jobScheduler, never()).cancel(model1.id) verify(jobScheduler, never()).cancel(any())
val jobInfo = jobCaptor.allValues.single() val jobInfo = jobCaptor.allValues.single()
assertThat(jobInfo.id).isEqualTo(model1.id) assertThat(jobInfo.id).isEqualTo(model1.id)
assertThat(jobInfo.extras.getInt(KEY_SITE_ID)).isEqualTo(model1.id) assertThat(jobInfo.extras.getLong(KEY_SITE_ID)).isEqualTo(model1.id)
} }
@Test fun scheduleCheck() { @Test fun scheduleValidation() {
val model1 = fakeModel() val model1 = fakeModel(id = 1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>()) whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>())
manager.scheduleCheck( manager.scheduleValidation(
site = model1, site = model1,
fromFinishingJob = true fromFinishingJob = true
) )
val jobCaptor = argumentCaptor<JobInfo>() val jobCaptor = argumentCaptor<JobInfo>()
verify(jobScheduler).schedule(jobCaptor.capture()) verify(jobScheduler).schedule(jobCaptor.capture())
verify(jobScheduler, never()).cancel(model1.id) verify(jobScheduler, never()).cancel(any())
val jobInfo = jobCaptor.allValues.single() val jobInfo = jobCaptor.allValues.single()
assertThat(jobInfo.id).isEqualTo(model1.id) assertThat(jobInfo.id).isEqualTo(model1.id)
assertThat(jobInfo.extras.getInt(KEY_SITE_ID)).isEqualTo(model1.id) assertThat(jobInfo.extras.getLong(KEY_SITE_ID)).isEqualTo(model1.id)
} }
@Test fun cancelCheck() { @Test fun cancelScheduledValidation() {
val model1 = fakeModel() val model1 = fakeModel(id = 1)
manager.cancelCheck(model1) manager.cancelScheduledValidation(model1)
verify(jobScheduler).cancel(model1.id) verify(jobScheduler).cancel(1)
} }
@Test fun performCheck_httpNotSuccess() = runBlocking { @Test fun performValidation_httpNotSuccess() = runBlocking {
val response = fakeResponse(500, "Internal Server Error", "Hello World") val response = fakeResponse(500, "Internal Server Error", "Hello World")
val call = mock<Call> { val call = mock<Call> {
on { execute() } doReturn response on { execute() } doReturn response
} }
whenever(okHttpClient.newCall(any())).doReturn(call) whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel() val model1 = fakeModel(id = 1)
val result = manager.performCheck(model1) val result = manager.performValidation(model1)
assertThat(result.model).isEqualTo( assertThat(result.model).isEqualTo(
model1.copy( model1.copy(
lastResult = model1.lastResult?.copy(
status = ERROR, status = ERROR,
reason = "Response 500 - Hello World" reason = "Response 500 - Hello World"
) )
) )
)
} }
@Test fun performCheck_socketTimeout() = runBlocking { @Test fun performValidation_socketTimeout() = runBlocking {
val error = SocketTimeoutException("Oh no!") val error = SocketTimeoutException("Oh no!")
val call = mock<Call> { val call = mock<Call> {
on { execute() } doAnswer { throw error } on { execute() } doAnswer { throw error }
} }
whenever(okHttpClient.newCall(any())).doReturn(call) whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel() val model1 = fakeModel(id = 1)
val result = manager.performCheck(model1) val result = manager.performValidation(model1)
assertThat(result.model).isEqualTo( assertThat(result.model).isEqualTo(
model1.copy( model1.copy(
lastResult = model1.lastResult?.copy(
status = ERROR, status = ERROR,
reason = timeoutError reason = timeoutError
) )
) )
)
} }
@Test fun performCheck_exception() = runBlocking { @Test fun performValidation_exception() = runBlocking {
val error = Exception("Oh no!") val error = Exception("Oh no!")
val call = mock<Call> { val call = mock<Call> {
on { execute() } doAnswer { throw error } on { execute() } doAnswer { throw error }
} }
whenever(okHttpClient.newCall(any())).doReturn(call) whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel() val model1 = fakeModel(id = 1)
val result = manager.performCheck(model1) val result = manager.performValidation(model1)
assertThat(result.model).isEqualTo( assertThat(result.model).isEqualTo(
model1.copy( model1.copy(
lastResult = model1.lastResult?.copy(
status = ERROR, status = ERROR,
reason = "Oh no!" reason = "Oh no!"
) )
) )
)
} }
@Test fun performCheck_success() = runBlocking { @Test fun performValidation_success_withHeaders() = runBlocking {
val requestCaptor = argumentCaptor<Request>()
val response = fakeResponse(200, "OK", "Hello World")
val call = mock<Call> {
on { execute() } doReturn response
}
whenever(okHttpClient.newCall(requestCaptor.capture()))
.doReturn(call)
val model1 = fakeModel(id = 1).copy(
headers = listOf(
Header(
key = "X-Test-Header",
value = "Hello, World!"
)
)
)
val result = manager.performValidation(model1)
val httpRequest = requestCaptor.firstValue
assertThat(result.model).isEqualTo(
model1.copy(
lastResult = model1.lastResult?.copy(
status = OK,
reason = null
)
)
)
assertThat(okHttpClient.callTimeoutMillis())
.isEqualTo(model1.settings!!.networkTimeout)
assertThat(httpRequest.header("X-Test-Header"))
.isEqualTo("Hello, World!")
}
@Test fun performValidation_success_withCustomSslCert() = runBlocking<Unit> {
val response = fakeResponse(200, "OK", "Hello World") val response = fakeResponse(200, "OK", "Hello World")
val call = mock<Call> { val call = mock<Call> {
on { execute() } doReturn response on { execute() } doReturn response
} }
whenever(okHttpClient.newCall(any())).doReturn(call) whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel() val model1 = fakeModel(id = 1).copy(
val result = manager.performCheck(model1) url = "http://wwww.mysite.com/test.html",
headers = emptyList()
)
model1.settings = model1.settings!!.copy(
certificate = "file:///sdcard/cert.pem"
)
val result = manager.performValidation(model1)
assertThat(result.model).isEqualTo( assertThat(result.model).isEqualTo(
model1.copy( model1.copy(
lastResult = model1.lastResult?.copy(
status = OK, status = OK,
reason = null reason = null
) )
) )
)
assertThat(okHttpClient.callTimeoutMillis()) assertThat(okHttpClient.callTimeoutMillis())
.isEqualTo(model1.networkTimeout) .isEqualTo(model1.settings!!.networkTimeout)
}
@Test fun performCheck_401_butStillSuccess() = runBlocking { verify(sslManager).clientForCertificate(
val response = fakeResponse(401, "Unauthorized", "Hello World") "file:///sdcard/cert.pem",
val call = mock<Call> { "http://wwww.mysite.com/test.html",
on { execute() } doReturn response okHttpClient
}
whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel()
val result = manager.performCheck(model1)
assertThat(result.model).isEqualTo(
model1.copy(
status = OK,
reason = null
)
) )
} }
@ -293,14 +337,6 @@ class CheckStatusManagerTest {
.build() .build()
} }
private fun fakeModel() = ServerModel(
id = 1,
name = "Wakanda Forever",
url = "https://www.wakanda.gov",
validationMode = STATUS_CODE,
networkTimeout = 60000
)
private fun fakeJob(id: Int): JobInfo { private fun fakeJob(id: Int): JobInfo {
return mock { return mock {
on { this.id } doReturn id on { this.id } doReturn id

View file

@ -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 # 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 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true # org.gradle.parallel=true
android.useAndroidX=true
android.enableJetifier=true

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

View file

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

13
mock/mock.gradle Normal file
View file

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

View file

@ -19,7 +19,8 @@ import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import com.afollestad.nocknock.notifications.Channel.CheckFailures import com.afollestad.nocknock.notifications.Channel.ValidationErrors
import com.afollestad.nocknock.notifications.Channel.ValidationSuccess
import com.afollestad.nocknock.utilities.providers.CanNotifyModel import com.afollestad.nocknock.utilities.providers.CanNotifyModel
import com.afollestad.nocknock.utilities.providers.IntentProvider import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider
@ -41,12 +42,13 @@ import org.junit.Test
class NockNotificationManagerTest { class NockNotificationManagerTest {
private val appIconRes = 1024
private val somethingWentWrong = "something went wrong" private val somethingWentWrong = "something went wrong"
private val yay = "yay!"
private val stockManager = mock<NotificationManager>() private val stockManager = mock<NotificationManager>()
private val stringProvider = mock<StringProvider> { private val stringProvider = mock<StringProvider> {
on { get(R.string.something_wrong) } doReturn somethingWentWrong on { get(R.string.something_wrong) } doReturn somethingWentWrong
on { get(R.string.validation_passed) } doReturn yay
} }
private val intentProvider = mock<IntentProvider>() private val intentProvider = mock<IntentProvider>()
private val channelProvider = mock<NotificationChannelProvider>() private val channelProvider = mock<NotificationChannelProvider>()
@ -77,30 +79,42 @@ class NockNotificationManagerTest {
@Test fun createChannels() { @Test fun createChannels() {
whenever(stringProvider.get(any())).doReturn("") whenever(stringProvider.get(any())).doReturn("")
val createdChannel = mock<NotificationChannel> { val errorChannel = mock<NotificationChannel> {
on { this.id } doReturn CheckFailures.id on { this.id } doReturn ValidationErrors.id
}
val successChannel = mock<NotificationChannel> {
on { this.id } doReturn ValidationSuccess.id
}
whenever(channelProvider.create(any(), any(), any(), any())).doAnswer { inv ->
val channelId = inv.getArgument<String>(0)
when (channelId) {
ValidationErrors.id -> errorChannel
ValidationSuccess.id -> successChannel
else -> null
}
} }
whenever(channelProvider.create(any(), any(), any(), any()))
.doReturn(createdChannel)
manager.createChannels() manager.createChannels()
val captor = argumentCaptor<NotificationChannel>() val captor = argumentCaptor<NotificationChannel>()
verify(stockManager, times(1)).createNotificationChannel(captor.capture()) verify(stockManager, times(2)).createNotificationChannel(captor.capture())
val channel = captor.allValues.single() val channels = captor.allValues
assertThat(channel.id).isEqualTo(CheckFailures.id) assertThat(channels.size).isEqualTo(2)
assertThat(channels.first()).isEqualTo(successChannel)
assertThat(channels.last()).isEqualTo(errorChannel)
verifyNoMoreInteractions(stockManager) verifyNoMoreInteractions(stockManager)
} }
@Test fun postStatusNotification_appIsOpen() { @Test fun postValidationSuccessNotification_appIsOpen() {
manager.setIsAppOpen(true) manager.setIsAppOpen(true)
manager.postStatusNotification(fakeModel()) manager.postValidationSuccessNotification(fakeModel())
verifyNoMoreInteractions(stockManager) verifyNoMoreInteractions(stockManager)
} }
@Test fun postStatusNotification_appNotOpen() { @Test fun postValidationSuccessNotification_appNotOpen() {
manager.setIsAppOpen(false) manager.setIsAppOpen(false)
val model = fakeModel() val model = fakeModel()
@ -111,15 +125,15 @@ class NockNotificationManagerTest {
val notification = mock<Notification>() val notification = mock<Notification>()
whenever( whenever(
notificationProvider.create( notificationProvider.create(
CheckFailures.id, ValidationErrors.id,
"Testing", "Testing",
somethingWentWrong, yay,
pendingIntent, pendingIntent,
R.drawable.ic_notification R.drawable.ic_notification_success
) )
).doReturn(notification) ).doReturn(notification)
manager.postStatusNotification(model) manager.postValidationSuccessNotification(model)
verify(stockManager).notify( verify(stockManager).notify(
"https://hello.com", "https://hello.com",
@ -129,6 +143,13 @@ class NockNotificationManagerTest {
verifyNoMoreInteractions(stockManager) verifyNoMoreInteractions(stockManager)
} }
@Test fun postValidationErrorNotification_appIsOpen() {
manager.setIsAppOpen(true)
manager.postValidationErrorNotification(fakeModel())
verifyNoMoreInteractions(stockManager)
}
@Test fun cancelStatusNotification() { @Test fun cancelStatusNotification() {
val model = fakeModel() val model = fakeModel()
manager.cancelStatusNotification(model) manager.cancelStatusNotification(model)
@ -148,5 +169,7 @@ class NockNotificationManagerTest {
override fun notifyName() = "Testing" override fun notifyName() = "Testing"
override fun notifyTag() = "https://hello.com" override fun notifyTag() = "https://hello.com"
override fun notifyDescription() = "Hello, World!"
} }
} }

View file

@ -18,6 +18,8 @@ dependencies {
implementation project(':common') implementation project(':common')
implementation project(':data') implementation project(':data')
api 'com.afollestad:vvalidator:' + versions.vvalidator
implementation 'androidx.appcompat:appcompat:' + versions.androidxCore implementation 'androidx.appcompat:appcompat:' + versions.androidxCore
implementation 'com.google.android.material:material:' + versions.googleMaterial implementation 'com.google.android.material:material:' + versions.googleMaterial
api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle

View file

@ -17,37 +17,24 @@ package com.afollestad.nocknock.viewcomponents.ext
import android.view.View import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewTreeObserver
import androidx.annotation.DimenRes import androidx.annotation.DimenRes
import com.afollestad.vvalidator.form.Condition
fun View.show() { fun View.show() {
visibility = VISIBLE visibility = VISIBLE
} }
fun View.conceal() {
visibility = INVISIBLE
}
fun View.hide() { fun View.hide() {
visibility = GONE visibility = GONE
} }
fun View.showOrHide(show: Boolean) = if (show) show() else hide() 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.dimenFloat(@DimenRes res: Int) = resources.getDimension(res)
fun View.dimenInt(@DimenRes res: Int) = resources.getDimensionPixelSize(res) fun View.dimenInt(@DimenRes res: Int) = resources.getDimensionPixelSize(res)
fun View.isVisibleCondition(): Condition = {
visibility == VISIBLE
}

View file

@ -56,10 +56,23 @@ class HeaderStackLayout(
override fun onClick(v: View) { override fun onClick(v: View) {
val index = v.tag as Int val index = v.tag as Int
check(index >= 0 || index < list.childCount) {
"Index $index is out of bounds in the header stack (size ${list.childCount})."
}
list.post {
list.removeViewAt(index) list.removeViewAt(index)
headers.removeAt(index) headers.removeAt(index)
invalidateTags()
postLiveData() 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) { private fun addEntry(forHeader: Header) {
// Keep track of reference for posting future changes. // Keep track of reference for posting future changes.
@ -67,9 +80,7 @@ class HeaderStackLayout(
val li = LayoutInflater.from(context) val li = LayoutInflater.from(context)
val entry = li.inflate(R.layout.header_stack_item, list, false) as HeaderItemLayout val entry = li.inflate(R.layout.header_stack_item, list, false) as HeaderItemLayout
list.addView(entry) list.addView(entry.apply {
entry.run {
inputKey.setText(forHeader.key) inputKey.setText(forHeader.key)
inputKey.post { entry.inputKey.requestFocus() } inputKey.post { entry.inputKey.requestFocus() }
attachHeader(forHeader, this@HeaderStackLayout) attachHeader(forHeader, this@HeaderStackLayout)
@ -77,6 +88,6 @@ class HeaderStackLayout(
btnRemove.tag = headers.size - 1 btnRemove.tag = headers.size - 1
btnRemove.setOnClickListener(this@HeaderStackLayout) btnRemove.setOnClickListener(this@HeaderStackLayout)
} })
} }
} }

View file

@ -19,7 +19,6 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.utilities.ext.DAY import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR 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.R
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner 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.input
import kotlinx.android.synthetic.main.validation_interval_layout.view.spinner import kotlinx.android.synthetic.main.validation_interval_layout.view.spinner
@ -66,8 +65,14 @@ class ValidationIntervalLayout(
fun attach( fun attach(
valueData: MutableLiveData<Int>, valueData: MutableLiveData<Int>,
multiplierData: MutableLiveData<Long>, multiplierData: MutableLiveData<Long>,
errorData: LiveData<Int?> 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) input.attachLiveData(lifecycleOwner(), valueData)
spinner.attachLiveData( spinner.attachLiveData(
lifecycleOwner = lifecycleOwner(), lifecycleOwner = lifecycleOwner(),
@ -91,10 +96,5 @@ class ValidationIntervalLayout(
} }
} }
) )
errorData.toViewError(lifecycleOwner(), this, ::setError)
}
private fun setError(error: String?) {
input.error = error
} }
} }

View file

@ -20,15 +20,17 @@ import android.util.AttributeSet
import android.widget.HorizontalScrollView import android.widget.HorizontalScrollView
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.R.dimen import com.afollestad.nocknock.viewcomponents.R.dimen
import com.afollestad.nocknock.viewcomponents.R.layout import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.dimenInt import com.afollestad.nocknock.viewcomponents.ext.dimenInt
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.showOrHide import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility 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.error_text
import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput
@ -50,16 +52,25 @@ class JavaScriptInputLayout(
contentInset // bottom contentInset // bottom
) )
elevation = dimenFloat(dimen.default_elevation) elevation = dimenFloat(dimen.default_elevation)
inflate(context, layout.javascript_input_layout, this) inflate(context, R.layout.javascript_input_layout, this)
} }
fun attach( fun attach(
codeData: MutableLiveData<String>, codeData: MutableLiveData<String>,
errorData: LiveData<Int?>, visibility: LiveData<Boolean>,
visibility: LiveData<Boolean> 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) userInput.attachLiveData(lifecycleOwner(), codeData)
errorData.toViewError(lifecycleOwner(), this, ::setError)
visibility.toViewVisibility(lifecycleOwner(), this) visibility.toViewVisibility(lifecycleOwner(), this)
} }

View file

@ -24,6 +24,7 @@ import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.ext.asSafeInt import com.afollestad.nocknock.viewcomponents.ext.asSafeInt
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner 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.minutes
import kotlinx.android.synthetic.main.retry_policy_layout.view.times import kotlinx.android.synthetic.main.retry_policy_layout.view.times
import kotlinx.android.synthetic.main.retry_policy_layout.view.retry_policy_desc as description import kotlinx.android.synthetic.main.retry_policy_layout.view.retry_policy_desc as description
@ -41,7 +42,8 @@ class RetryPolicyLayout(
fun attach( fun attach(
timesData: MutableLiveData<Int>, timesData: MutableLiveData<Int>,
minutesData: MutableLiveData<Int> minutesData: MutableLiveData<Int>,
form: Form
) { ) {
times.attachLiveData(lifecycleOwner(), timesData) times.attachLiveData(lifecycleOwner(), timesData)
minutes.attachLiveData(lifecycleOwner(), minutesData) minutes.attachLiveData(lifecycleOwner(), minutesData)
@ -50,6 +52,13 @@ class RetryPolicyLayout(
minutes.onTextChanged { invalidateDescriptionText() } minutes.onTextChanged { invalidateDescriptionText() }
invalidateDescriptionText() invalidateDescriptionText()
form.input(times, optional = true) {
isNumber().greaterThan(0)
}
form.input(minutes, optional = true) {
isNumber().greaterThan(0)
}
} }
private fun invalidateDescriptionText() { private fun invalidateDescriptionText() {

View file

@ -12,7 +12,7 @@
android:id="@+id/label" android:id="@+id/label"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/check_interval" android:text="@string/validation_interval"
style="@style/NockText.SectionHeader" style="@style/NockText.SectionHeader"
/> />
@ -27,7 +27,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/check_interval_every" android:text="@string/validation_interval_every"
style="@style/NockText.Body" style="@style/NockText.Body"
/> />

View file

@ -5,8 +5,8 @@
<string name="function_declaration">function validate(response) {</string> <string name="function_declaration">function validate(response) {</string>
<string name="function_end">}</string> <string name="function_end">}</string>
<string name="check_interval">Check Interval</string> <string name="validation_interval">Validation Interval</string>
<string name="check_interval_every">Every</string> <string name="validation_interval_every">Every</string>
<string name="retry_policy">Retry Policy</string> <string name="retry_policy">Retry Policy</string>
<string name="retry_policy_retry">Retry</string> <string name="retry_policy_retry">Retry</string>
@ -23,4 +23,8 @@
<string name="header_name">Header Name</string> <string name="header_name">Header Name</string>
<string name="header_value">Header Value</string> <string name="header_value">Header Value</string>
<string name="please_enter_check_interval">Please input a validation interval.</string>
<string name="check_interval_must_be_greater_zero">The validation interval must be greater than 0.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
</resources> </resources>

View file

@ -52,4 +52,14 @@
<item name="android:textColor">@color/md_red</item> <item name="android:textColor">@color/md_red</item>
</style> </style>
<style name="AccentButton" parent="Widget.MaterialComponents.Button">
<item name="android:textColor">#fff</item>
<item name="backgroundTint">?colorAccent</item>
<item name="android:fontFamily">@font/lato</item>
</style>
<style name="AccentTextButton" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:fontFamily">@font/lato</item>
</style>
</resources> </resources>