Compare commits

..

64 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
Aidan Follestad
f207ed5f78 0.8.4 2019-01-08 16:51:31 -08:00
Aidan Follestad
cbac2796aa Show success notification if validation passes after previously not passing. Resolves #4. 2019-01-08 16:39:00 -08:00
Aidan Follestad
e3820fd7d3 Show more detail in error notifications 2019-01-08 16:30:18 -08:00
Aidan Follestad
8dc2112e2d Since headers can now be sent, consider 401 an error code 2019-01-08 16:27:26 -08:00
Aidan Follestad
74f7aa8aa2 Add the ability to duplicate sites in the long-press menu. Resolves #40. 2019-01-08 16:21:26 -08:00
Aidan Follestad
646bc25232 Improved a lot of the UI, cleaned up some stuff. Add the ability to add headers to sites, resolves #39. 2019-01-08 16:14:08 -08:00
Aidan Follestad
26ab76b363 Avoid divide by zero crash in RetryPolicy 2019-01-08 10:46:02 -08:00
Aidan Follestad
56030af0f0 Use issue/PR templates 2019-01-08 10:11:04 -08:00
Aidan Follestad
7f8db7b7d5 Update showcase image 2019-01-07 23:34:30 -08:00
116 changed files with 2384 additions and 1415 deletions

View file

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

View file

@ -1,28 +0,0 @@
(`[x]` becomes a filled in checkbox, `[ ]` is an empty one)
- [ ] I have verified there are [no duplicate active or recent bugs, questions, or requests](https://github.com/afollestad/nock-nock/issues?q=is%3Aissue+is%3Aclosed)
- [ ] I have given my issue a non-generic title.
---
If this is a improvement or feature request, you can remove everything below.
Also, please consider making a pull request if you are capable of contributing.
###### Include the following:
- Nock Nock version: `0.x.x`
- Affected device: Google Pixel 3 XL with Android 9.0
---
###### Reproduction Steps
1.
---
###### Expected Result
---
###### Actual Result

28
.github/ISSUE_TEMPLATE/Bug_report.md vendored Normal file
View file

@ -0,0 +1,28 @@
---
name: Bug report
about: Something is crashing or not working as intended
---
*Please consider making a Pull Request if you are capable of doing so.*
**App Version:**
x.x.x
**Affected Device(s):**
Google Pixel 3 XL with Android 9.0
**Describe the Bug:**
A clear description of what is the bug is.
**To Reproduce:**
1.
2.
3.
**Expected Behavior:**
A clear description of what you expected to happen.

View file

@ -0,0 +1,15 @@
---
name: Feature request
about: Suggest an idea for this project
---
*Please consider making a Pull Request if you are capable of doing so.*
**Description what you'd like to happen:**
A clear description if the feature or behavior you'd like implemented.
**Describe alternatives you've considered:**
A clear description of any alternative solutions you've considered.

View file

@ -1,9 +1,8 @@
### Guidelines
1. You must run the `spotlessApply` task before commiting, either through Android Studio or with `./gradlew spotlessApply`.
1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`.
2. A PR should be focused and contained. If you are changing multiple unrelated things, they should be in separate PRs.
3. A PR should fix a bug or solve a problem - something that only you would use is not necessarily something that should be published.
4. Give your PR a detailed title and description - look over your code one last time before actually creating the PR. Give it a self-review.
**If you do not follow the guidelines, your PR will be rejected.**
**If you do not follow the guidelines, your PR will be rejected.**

4
.gitignore vendored
View file

@ -180,4 +180,6 @@ gradle-app.setting
.gradletasknamecache
# # 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>
</option>
</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" />
</component>
<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
[![Build Status](https://travis-ci.org/afollestad/nock-nock.svg)](https://travis-ci.org/afollestad/nock-nock)
[![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html)
![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcasemain3.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.

View file

@ -4,18 +4,6 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'io.fabric'
def getFabricApiKey() {
def propsFile = project.rootProject.file('local.properties')
if (!propsFile.exists()) {
return ""
}
Properties properties = new Properties()
properties.load(propsFile.newDataInputStream())
return properties.getProperty("fabric.apikey") ?: ""
}
android {
compileSdkVersion versions.compileSdk
buildToolsVersion versions.buildTools
@ -26,16 +14,15 @@ android {
targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode
versionName versions.publishVersion
manifestPlaceholders = [fabricKey:getFabricApiKey()]
}
buildTypes {
debug {
buildConfigField "String", "FABRIC_API_KEY", "\"\""
}
release {
buildConfigField "String", "FABRIC_API_KEY", "\"${getFabricApiKey()}\""
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
}
@ -51,6 +38,7 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView
implementation 'com.google.android.material:material:' + versions.googleMaterial
implementation 'androidx.browser:browser:' + versions.androidxBrowser
implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore
// Lifecycle
kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle
@ -84,4 +72,8 @@ dependencies {
androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner
}
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>
</receiver>
<meta-data
android:name="io.fabric.ApiKey"
android:value="${fabricKey}"/>
<meta-data
android:name="preloaded_fonts"
android:resource="@array/preloaded_fonts"/>

View file

@ -20,12 +20,12 @@ import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import androidx.core.text.HtmlCompat.fromHtml
import com.afollestad.materialdialogs.utils.MDUtil.resolveColor
import com.afollestad.nocknock.utilities.ext.toUri
import com.afollestad.nocknock.utilities.ui.toast
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.toUri() = Uri.parse(this)!!
fun Activity.viewUrl(url: String) {
val customTabsIntent = CustomTabsIntent.Builder()
.apply {

View file

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

View file

@ -24,6 +24,8 @@ import androidx.room.Room.databaseBuilder
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.Database1to2Migration
import com.afollestad.nocknock.data.Database2to3Migration
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.ui.main.MainActivity
import com.afollestad.nocknock.utilities.ext.systemService
@ -41,7 +43,9 @@ val mainModule = module {
databaseBuilder(get(), AppDatabase::class.java, "NockNock.db")
.addMigrations(
Database1to2Migration(),
Database2to3Migration()
Database2to3Migration(),
Database3to4Migration(),
Database4to5Migration()
)
.build()
}

View file

@ -15,10 +15,15 @@
*/
package com.afollestad.nocknock.ui
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.afollestad.nocknock.R
import com.afollestad.nocknock.koin.PREF_DARK_MODE
import com.afollestad.nocknock.ui.NightMode.DISABLED
import com.afollestad.nocknock.ui.NightMode.ENABLED
import com.afollestad.nocknock.ui.NightMode.UNKNOWN
import com.afollestad.nocknock.utilities.rx.attachLifecycle
import com.afollestad.rxkprefs.Pref
import org.koin.android.ext.android.inject
@ -35,16 +40,35 @@ abstract class DarkModeSwitchActivity : AppCompatActivity() {
setTheme(themeRes())
super.onCreate(savedInstanceState)
darkModePref.observe()
.filter { it != isDarkModeEnabled }
.subscribe {
log("Theme changed, recreating Activity.")
recreate()
}
.attachLifecycle(this)
if (getCurrentNightMode() == UNKNOWN) {
darkModePref.observe()
.filter { it != isDarkModeEnabled }
.subscribe {
log("Theme changed, recreating Activity.")
recreate()
}
.attachLifecycle(this)
}
}
protected fun isDarkMode() = darkModePref.get()
protected fun getCurrentNightMode(): NightMode {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return UNKNOWN
}
return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
Configuration.UI_MODE_NIGHT_YES -> return ENABLED
Configuration.UI_MODE_NIGHT_NO -> return DISABLED
else -> UNKNOWN
}
}
protected fun isDarkMode(): Boolean {
return when (getCurrentNightMode()) {
ENABLED -> true
DISABLED -> false
else -> darkModePref.get()
}
}
protected fun toggleDarkMode() = setDarkMode(!isDarkMode())

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,17 +16,30 @@
package com.afollestad.nocknock.ui.addsite
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.widget.ArrayAdapter
import androidx.lifecycle.Observer
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
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.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
import kotlinx.android.synthetic.main.activity_addsite.headersLayout
import kotlinx.android.synthetic.main.activity_addsite.inputName
import kotlinx.android.synthetic.main.activity_addsite.inputTags
import kotlinx.android.synthetic.main.activity_addsite.inputUrl
@ -36,47 +49,54 @@ import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout
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.validationModeDescription
import kotlinx.android.synthetic.main.include_app_bar.toolbar
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
/** @author Aidan Follestad (@afollestad) */
class AddSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
private val viewModel by viewModel<AddSiteViewModel>()
private lateinit var validationForm: Form
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_addsite)
setupUi()
setupValidation()
lifecycle.addObserver(viewModel)
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as? Site
model?.let { viewModel.prePopulateFromModel(model) }
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
// Name
inputName.attachLiveData(this, viewModel.name)
viewModel.onNameError()
.toViewError(this, inputName)
// Tags
inputTags.attachLiveData(this, viewModel.tags)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlError()
.toViewError(this, inputUrl)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
viewModel.onTimeoutError()
.toViewError(this, responseTimeoutInput)
// Validation mode
responseValidationMode.attachLiveData(
@ -85,8 +105,6 @@ class AddSiteActivity : DarkModeSwitchActivity() {
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
@ -99,30 +117,19 @@ class AddSiteActivity : DarkModeSwitchActivity() {
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
errorData = viewModel.onValidationScriptError(),
visibility = viewModel.onValidationScriptVisibility()
)
// SSL certificate
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
viewModel.certificateUri.distinct()
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
errorData = viewModel.onCheckIntervalError()
)
// Retry Policy
retryPolicyLayout.attach(
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes
)
// Headers
headersLayout.attach(viewModel.headers)
}
private fun setupUi() {
toolbarTitle.setText(R.string.add_site)
toolbar.run {
inflateMenu(R.menu.menu_addsite)
setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() }
}
@ -135,12 +142,94 @@ class AddSiteActivity : DarkModeSwitchActivity() {
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
// Done button
doneBtn.setOnClickListener {
viewModel.commit {
setResult(RESULT_OK)
finish()
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
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

@ -25,6 +25,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
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
@ -35,11 +36,10 @@ import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.data.putSite
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.livedata.map
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -49,7 +49,7 @@ import java.lang.System.currentTimeMillis
/** @author Aidan Follestad (@afollestad) */
class AddSiteViewModel(
private val database: AppDatabase,
private val validationManager: ValidationManager,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
@ -66,6 +66,8 @@ class AddSiteViewModel(
val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>()
val certificateUri = MutableLiveData<String>()
@OnLifecycleEvent(ON_START)
fun setDefaults() {
@ -76,24 +78,13 @@ class AddSiteViewModel(
retryPolicyMinutes.value = 0
retryPolicyMinutes.value = 0
tags.value = ""
headers.value = emptyList()
}
// Private properties
private val isLoading = MutableLiveData<Boolean>()
private val nameError = MutableLiveData<Int?>()
private val urlError = MutableLiveData<Int?>()
private val timeoutError = MutableLiveData<Int?>()
private val validationSearchTermError = MutableLiveData<Int?>()
private val validationScriptError = MutableLiveData<Int?>()
private val checkIntervalValueError = MutableLiveData<Int?>()
// Expose private properties or calculated properties
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onNameError(): LiveData<Int?> = nameError
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map {
val parsed = HttpUrl.parse(it)
@ -101,8 +92,6 @@ class AddSiteViewModel(
}
}
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map {
when (it!!) {
@ -113,17 +102,9 @@ class AddSiteViewModel(
}
}
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError
@CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationSearchTermVisibility() =
validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
@CheckResult fun onValidationScriptVisibility() =
validationMode.map { it == JAVASCRIPT }
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
@CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
// Actions
fun commit(done: () -> Unit) {
@ -134,7 +115,7 @@ class AddSiteViewModel(
val storedModel = withContext(ioDispatcher) {
database.putSite(newModel)
}
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = storedModel,
rightNow = true,
cancelPrevious = true
@ -163,70 +144,7 @@ class AddSiteViewModel(
}
private fun generateDbModel(): Site? {
var errorCount = 0
// Validation name
if (name.value.isNullOrEmpty()) {
nameError.value = R.string.please_enter_name
errorCount++
} else {
nameError.value = null
}
// Validate URL
when {
url.value.isNullOrEmpty() -> {
urlError.value = R.string.please_enter_url
errorCount++
}
HttpUrl.parse(url.value!!) == null -> {
urlError.value = R.string.please_enter_valid_url
errorCount++
}
else -> {
urlError.value = null
}
}
// Validate timeout
val timeout = timeout.value ?: 10_000
if (timeout < 0) {
timeoutError.value = R.string.please_enter_networkTimeout
errorCount++
} else {
timeoutError.value = null
}
// Validate check interval
if (checkIntervalValue.value.isNullOrLessThan(1)) {
checkIntervalValueError.value = R.string.please_enter_check_interval
errorCount++
} else {
checkIntervalValueError.value = null
}
// Validate arguments
if (validationMode.value == TERM_SEARCH &&
validationSearchTerm.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = R.string.please_enter_search_term
validationScriptError.value = null
} else if (validationMode.value == JAVASCRIPT &&
validationScript.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = null
validationScriptError.value = R.string.please_enter_javaScript
} else {
validationSearchTermError.value = null
validationScriptError.value = null
}
if (errorCount > 0) {
return null
}
val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
val newSettings = SiteSettings(
@ -234,7 +152,8 @@ class AddSiteViewModel(
validationMode = validationMode.value!!,
validationArgs = getValidationArgs(),
networkTimeout = timeout,
disabled = false
disabled = false,
certificate = certificateUri.value?.toString()
)
val newLastResult = ValidationResult(
@ -247,7 +166,8 @@ class AddSiteViewModel(
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
RetryPolicy(
count = retryPolicyTimes, minutes = retryPolicyMinutes
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
} else {
null
@ -260,7 +180,8 @@ class AddSiteViewModel(
tags = cleanedTags,
settings = newSettings,
lastResult = newLastResult,
retryPolicy = newRetryPolicy
retryPolicy = newRetryPolicy,
headers = headers.value ?: emptyList()
)
}
}

View file

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

View file

@ -24,7 +24,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItems
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.SiteAdapter
import com.afollestad.nocknock.adapter.TagAdapter
@ -33,10 +32,8 @@ import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.dialogs.AboutDialog
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.ui.NightMode.UNKNOWN
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.utilities.ui.toast
import com.afollestad.nocknock.viewUrl
import com.afollestad.nocknock.viewUrlWithApp
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list
@ -93,12 +90,17 @@ class MainActivity : DarkModeSwitchActivity() {
toolbar.run {
inflateMenu(R.menu.menu_main)
menu.findItem(R.id.dark_mode)
.isChecked = isDarkMode()
.apply {
if (getCurrentNightMode() == UNKNOWN) {
isChecked = isDarkMode()
} else {
isVisible = false
}
}
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.about -> AboutDialog.show(this@MainActivity)
R.id.dark_mode -> toggleDarkMode()
R.id.support_me -> supportMe()
}
return@setOnMenuItemClickListener true
}
@ -135,7 +137,8 @@ class MainActivity : DarkModeSwitchActivity() {
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
0 -> viewModel.refreshSite(model)
1 -> maybeRemoveSite(model)
1 -> addSiteForDuplication(model)
2 -> maybeRemoveSite(model)
}
}
}
@ -143,20 +146,4 @@ class MainActivity : DarkModeSwitchActivity() {
viewSite(model)
}
}
private fun supportMe() {
MaterialDialog(this).show {
title(R.string.support_me)
message(R.string.support_me_message, html = true, lineHeightMultiplier = 1.4f)
listItemsSingleChoice(R.array.donation_options) { _, index, _ ->
when (index) {
0 -> viewUrl("https://paypal.me/AidanFollestad")
1 -> viewUrlWithApp("https://cash.me/\$afollestad", pkg = "com.squareup.cash")
2 -> viewUrlWithApp("https://venmo.com/afollestad", pkg = "com.venmo")
}
toast(R.string.thank_you)
}
positiveButton(R.string.next)
}
}
}

View file

@ -28,10 +28,23 @@ import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.
internal const val VIEW_SITE_RQ = 6923
internal const val ADD_SITE_RQ = 6969
// ADD
internal fun MainActivity.addSite() {
startActivityForResult(Intent(this, AddSiteActivity::class.java), ADD_SITE_RQ)
startActivityForResult(intentToAdd(), ADD_SITE_RQ)
}
internal fun MainActivity.addSiteForDuplication(site: Site) {
startActivityForResult(intentToAdd(site), ADD_SITE_RQ)
}
private fun MainActivity.intentToAdd(model: Site? = null) =
Intent(this, AddSiteActivity::class.java).apply {
model?.let { putExtra(KEY_SITE, it) }
}
// VIEW
internal fun MainActivity.viewSite(model: Site) {
startActivityForResult(intentToView(model), VIEW_SITE_RQ)
}
@ -41,6 +54,8 @@ private fun MainActivity.intentToView(model: Site) =
putExtra(KEY_SITE, model)
}
// MISC
internal fun MainActivity.maybeRemoveSite(model: Site) {
MaterialDialog(this).show {
title(R.string.remove_site)

View file

@ -25,7 +25,7 @@ import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.allSites
import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.ScopedViewModel
import kotlinx.coroutines.CoroutineDispatcher
@ -36,7 +36,7 @@ import kotlinx.coroutines.withContext
class MainViewModel(
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val validationManager: ValidationManager,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
@ -73,7 +73,7 @@ class MainViewModel(
}
fun refreshSite(model: Site) {
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = model,
rightNow = true,
cancelPrevious = true
@ -81,7 +81,7 @@ class MainViewModel(
}
fun removeSite(model: Site) {
validationManager.cancelCheck(model)
validationManager.cancelScheduledValidation(model)
notificationManager.cancelStatusNotification(model)
scope.launch {
@ -134,7 +134,7 @@ class MainViewModel(
private suspend fun ensureCheckJobs() {
withContext(ioDispatcher) {
validationManager.ensureScheduledChecks()
validationManager.ensureScheduledValidations()
}
}

View file

@ -17,6 +17,8 @@ package com.afollestad.nocknock.ui.viewsite
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.widget.ArrayAdapter
import androidx.lifecycle.Observer
@ -25,16 +27,20 @@ import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode
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.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
import kotlinx.android.synthetic.main.activity_viewsite.headersLayout
import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
import kotlinx.android.synthetic.main.activity_viewsite.inputName
import kotlinx.android.synthetic.main.activity_viewsite.inputTags
@ -46,6 +52,8 @@ import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearch
import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout
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.textNextCheck
import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning
@ -53,12 +61,17 @@ import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescriptio
import kotlinx.android.synthetic.main.include_app_bar.toolbar
import org.koin.android.ext.android.inject
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
/** @author Aidan Follestad (@afollestad) */
class ViewSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
internal val viewModel by viewModel<ViewSiteViewModel>()
private lateinit var validationForm: Form
private val intentProvider by inject<IntentProvider>()
private val statusUpdateReceiver by lazy {
@ -71,17 +84,18 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_viewsite)
setupUi()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(model)
setupUi()
setupValidation()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
@ -93,23 +107,17 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
// Name
inputName.attachLiveData(this, viewModel.name)
viewModel.onNameError()
.toViewError(this, inputName)
// Tags
inputTags.attachLiveData(this, viewModel.tags)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlError()
.toViewError(this, inputUrl)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
viewModel.onTimeoutError()
.toViewError(this, responseTimeoutInput)
// Validation mode
responseValidationMode.attachLiveData(
@ -118,8 +126,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
@ -128,25 +134,13 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
errorData = viewModel.onValidationScriptError(),
visibility = viewModel.onValidationScriptVisibility()
)
// SSL certificate
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
viewModel.certificateUri.distinct()
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
errorData = viewModel.onCheckIntervalError()
)
// Retry Policy
retryPolicyLayout.attach(
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes
)
// Headers
headersLayout.attach(viewModel.headers)
// Last/next check
viewModel.onLastCheckResultText()
@ -156,25 +150,30 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
}
private fun setupUi() {
toolbarTitle.setText(R.string.view_site)
toolbarTitle.text = ""
toolbar.run {
setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() }
inflateMenu(R.menu.menu_viewsite)
menu.findItem(R.id.refresh)
.setActionView(R.layout.menu_item_refresh_icon)
.apply {
actionView.setOnClickListener { viewModel.checkNow() }
}
setOnMenuItemClickListener {
maybeRemoveSite()
when (it.itemId) {
R.id.remove -> maybeRemoveSite()
R.id.disableChecks -> maybeDisableChecks()
}
true
}
}
scrollView.onScroll {
toolbar.elevation = if (it > toolbar.height / 4) {
toolbar.dimenFloat(R.dimen.default_elevation)
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
@ -190,14 +189,95 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
// Disabled button
viewModel.onDisableChecksVisibility()
.toViewVisibility(this, disableChecksButton)
disableChecksButton.setOnClickListener { maybeDisableChecks() }
.observe(this, Observer {
toolbar.menu.findItem(R.id.disableChecks)
.isVisible = it
})
// Done button
// Done item text
viewModel.onDoneButtonText()
.toViewText(this, doneBtn)
doneBtn.setOnClickListener {
viewModel.commit { finish() }
.observe(this, Observer {
toolbar.menu.findItem(R.id.commit)
.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() ?: "")
}
}

View file

@ -23,8 +23,9 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.deleteSite
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.Status
import com.afollestad.nocknock.data.model.Status.WAITING
@ -35,14 +36,13 @@ import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.data.model.textRes
import com.afollestad.nocknock.data.updateSite
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.utilities.livedata.map
import com.afollestad.nocknock.utilities.livedata.zip
import com.afollestad.nocknock.utilities.providers.StringProvider
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -54,7 +54,7 @@ class ViewSiteViewModel(
private val stringProvider: StringProvider,
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val validationManager: ValidationManager,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
@ -74,25 +74,15 @@ class ViewSiteViewModel(
val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>()
val certificateUri = MutableLiveData<String>()
internal val disabled = MutableLiveData<Boolean>()
internal val lastResult = MutableLiveData<ValidationResult?>()
// Private properties
private val isLoading = MutableLiveData<Boolean>()
private val nameError = MutableLiveData<Int?>()
private val urlError = MutableLiveData<Int?>()
private val timeoutError = MutableLiveData<Int?>()
private val validationSearchTermError = MutableLiveData<Int?>()
private val validationScriptError = MutableLiveData<Int?>()
private val checkIntervalValueError = MutableLiveData<Int?>()
// Expose private properties or calculated properties
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onNameError(): LiveData<Int?> = nameError
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map {
val parsed = HttpUrl.parse(it)
@ -100,8 +90,6 @@ class ViewSiteViewModel(
}
}
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map {
when (it!!) {
@ -112,20 +100,11 @@ class ViewSiteViewModel(
}
}
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError
@CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationSearchTermVisibility() =
validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
@CheckResult fun onValidationScriptVisibility() =
validationMode.map { it == JAVASCRIPT }
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
@CheckResult fun onDisableChecksVisibility(): LiveData<Boolean> =
disabled.map { !it }
@CheckResult fun onDisableChecksVisibility(): LiveData<Boolean> = disabled.map { !it }
@CheckResult fun onDoneButtonText(): LiveData<Int> =
disabled.map {
@ -169,7 +148,7 @@ class ViewSiteViewModel(
withContext(ioDispatcher) {
database.updateSite(updatedModel)
}
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = updatedModel,
rightNow = true,
cancelPrevious = true
@ -185,7 +164,7 @@ class ViewSiteViewModel(
status = WAITING
)
setModel(checkModel)
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = checkModel,
rightNow = true,
cancelPrevious = true
@ -193,7 +172,7 @@ class ViewSiteViewModel(
}
fun removeSite(done: () -> Unit) {
validationManager.cancelCheck(site)
validationManager.cancelScheduledValidation(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
@ -207,7 +186,7 @@ class ViewSiteViewModel(
}
fun disableSite() {
validationManager.cancelCheck(site)
validationManager.cancelScheduledValidation(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
@ -243,78 +222,16 @@ class ViewSiteViewModel(
}
private fun getUpdatedDbModel(): Site? {
var errorCount = 0
// Validation name
if (name.value.isNullOrEmpty()) {
nameError.value = R.string.please_enter_name
errorCount++
} else {
nameError.value = null
}
// Validate URL
when {
url.value.isNullOrEmpty() -> {
urlError.value = R.string.please_enter_url
errorCount++
}
HttpUrl.parse(url.value!!) == null -> {
urlError.value = R.string.please_enter_valid_url
errorCount++
}
else -> {
urlError.value = null
}
}
// Validate timeout
val timeout = timeout.value ?: 10_000
if (timeout < 0) {
timeoutError.value = R.string.please_enter_networkTimeout
errorCount++
} else {
timeoutError.value = null
}
// Validate check interval
if (checkIntervalValue.value.isNullOrLessThan(1)) {
checkIntervalValueError.value = R.string.please_enter_check_interval
errorCount++
} else {
checkIntervalValueError.value = null
}
// Validate arguments
if (validationMode.value == TERM_SEARCH &&
validationSearchTerm.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = R.string.please_enter_search_term
validationScriptError.value = null
} else if (validationMode.value == JAVASCRIPT &&
validationScript.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = null
validationScriptError.value = R.string.please_enter_javaScript
} else {
validationSearchTermError.value = null
validationScriptError.value = null
}
if (errorCount > 0) {
return null
}
val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: ""
val newSettings = site.settings!!.copy(
validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!,
validationArgs = getValidationArgs(),
networkTimeout = timeout,
disabled = false
disabled = false,
certificate = certificateUri.value?.toString()
)
val retryPolicyTimes = retryPolicyTimes.value ?: 0
@ -322,11 +239,15 @@ class ViewSiteViewModel(
val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
if (site.retryPolicy != null) {
// Have existing policy, update it
site.retryPolicy!!.copy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
site.retryPolicy!!.copy(
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
} else {
// Create new policy
RetryPolicy(
count = retryPolicyTimes, minutes = retryPolicyMinutes
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
}
} else {
@ -339,7 +260,8 @@ class ViewSiteViewModel(
tags = cleanedTags,
url = url.value!!.trim(),
settings = newSettings,
retryPolicy = retryPolicy
retryPolicy = retryPolicy,
headers = headers.value ?: emptyList()
)
.withStatus(status = WAITING)
}

View file

@ -54,6 +54,12 @@ fun ViewSiteViewModel.setModel(site: Site) {
setCheckInterval(settings.validationIntervalMs)
setRetryPolicy(site.retryPolicy)
headers.value = site.headers
if (settings.certificate == "null") {
certificateUri.value = ""
} else {
certificateUri.value = settings.certificate
}
this.disabled.value = settings.disabled
this.lastResult.value = site.lastResult
@ -63,22 +69,22 @@ private fun ViewSiteViewModel.setCheckInterval(interval: Long) {
when {
interval >= WEEK -> {
checkIntervalValue.value =
getIntervalFromUnit(interval, WEEK)
getIntervalFromUnit(interval, WEEK)
checkIntervalUnit.value = WEEK
}
interval >= DAY -> {
checkIntervalValue.value =
getIntervalFromUnit(interval, DAY)
getIntervalFromUnit(interval, DAY)
checkIntervalUnit.value = DAY
}
interval >= HOUR -> {
checkIntervalValue.value =
getIntervalFromUnit(interval, HOUR)
getIntervalFromUnit(interval, HOUR)
checkIntervalUnit.value = HOUR
}
interval >= MINUTE -> {
checkIntervalValue.value =
getIntervalFromUnit(interval, MINUTE)
getIntervalFromUnit(interval, MINUTE)
checkIntervalUnit.value = MINUTE
}
else -> {

View file

@ -0,0 +1,10 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp">
<path
android:fillColor="?iconColor"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View file

@ -16,6 +16,7 @@
<include layout="@layout/include_app_bar"/>
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
@ -24,89 +25,62 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingBottom="@dimen/content_inset_double"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/nameTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:nextFocusDown="@+id/urlTiLayout"
>
<TextView
android:layout_marginTop="0dp"
android:text="@string/site_name"
style="@style/InputForm.Header"
/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
style="@style/NockText.Body"
/>
<EditText
android:id="@+id/inputName"
android:hint="@string/site_name_hint"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:text="@string/site_url"
style="@style/InputForm.Header"
/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/urlTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_half"
android:nextFocusDown="@+id/tagsTiLayout"
>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_url"
android:inputType="textUri"
android:nextFocusDown="@+id/inputTags"
style="@style/NockText.Body"
/>
</com.google.android.material.textfield.TextInputLayout>
<EditText
android:id="@+id/inputUrl"
android:hint="@string/site_url_hint"
android:inputType="textUri"
android:nextFocusDown="@+id/inputTags"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<TextView
android:id="@+id/textUrlWarning"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:text="@string/warning_http_url"
android:visibility="gone"
style="@style/NockText.Footnote"
style="@style/InputForm.FieldNote"
/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tagsTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_half"
android:nextFocusDown="@+id/urlTiLayout"
>
<TextView
android:text="@string/site_tags"
style="@style/InputForm.Header"
/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputTags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits=",.?-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "
android:hint="@string/site_tags_hint"
android:imeOptions="actionNext"
android:inputType="text|textCapWords"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
style="@style/NockText.Body"
/>
</com.google.android.material.textfield.TextInputLayout>
<EditText
android:id="@+id/inputTags"
android:digits=",.?-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "
android:hint="@string/site_tags_hint"
android:inputType="text|textCapWords"
android:nextFocusDown="@+id/inputUrl"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<include layout="@layout/include_divider"/>
@ -117,35 +91,10 @@
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"
/>
<TextView
android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_more"
android:text="@string/response_validation_mode"
style="@style/NockText.SectionHeader"
style="@style/InputForm.Header"
/>
<Spinner
@ -174,7 +123,7 @@
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_half"
android:background="@color/lighterGray"
android:background="?scriptLayoutBackground"
/>
<TextView
@ -187,12 +136,7 @@
style="@style/NockText.Body.Light"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
android:background="?dividerColor"
/>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
android:id="@+id/retryPolicyLayout"
@ -201,13 +145,66 @@
android:layout_marginTop="@dimen/content_inset_more"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/doneBtn"
<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:layout_marginTop="@dimen/content_inset_double"
android:text="@string/add_site"
style="@style/AccentButton"
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"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
android:id="@+id/headersLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
/>
</LinearLayout>

View file

@ -26,15 +26,30 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingBottom="@dimen/content_inset_double"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
android:paddingTop="@dimen/content_inset_less"
>
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Header"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_quarter"
android:orientation="horizontal"
>
@ -55,19 +70,6 @@
android:orientation="vertical"
>
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
<EditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
@ -124,35 +126,6 @@
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"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
android:background="?dividerColor"
/>
<TextView
android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content"
@ -188,7 +161,7 @@
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_half"
android:background="@color/lighterGray"
android:background="?scriptLayoutBackground"
/>
<TextView
@ -215,6 +188,76 @@
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"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
android:id="@+id/headersLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
/>
<include layout="@layout/include_divider"/>
<TextView
@ -251,24 +294,6 @@
style="@style/NockText.Body"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/doneBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_double"
android:text="@string/save_changes"
style="@style/AccentButton"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/disableChecksButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_quarter"
android:text="@string/disable_automatic_checks"
style="@style/PrimaryDarkButton"
/>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/commit"
android:icon="@drawable/ic_check"
android:title="@string/add_site"
app:showAsAction="ifRoom"/>
</menu>

View file

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

View file

@ -1,17 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/commit"
android:icon="@drawable/ic_check"
android:title="@string/save_changes"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/refresh"
android:icon="@drawable/ic_action_refresh"
android:title="@string/refresh_status"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/remove"
android:icon="@drawable/ic_action_delete"
android:title="@string/remove_site"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/disableChecks"
android:title="@string/disable_automatic_checks"
/>
</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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -3,6 +3,7 @@
<string-array name="site_long_options" translatable="false">
<item>@string/refresh_status</item>
<item>@string/duplicate_and_modify</item>
<item>@string/remove_site</item>
</string-array>
@ -12,10 +13,4 @@
<item>JavaScript Evaluation</item>
</string-array>
<string-array name="donation_options">
<item>via PayPal</item>
<item>via Cash App</item>
<item>via Venmo</item>
</string-array>
</resources>

View file

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

View file

@ -7,7 +7,9 @@
<color name="colorPrimary_darkTheme">#212121</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_pressed">#E44615</color>
<color name="colorAccent_translucent">#40FF6E40</color>

View file

@ -2,5 +2,6 @@
<dimen name="empty_text_size">28sp</dimen>
<dimen name="list_text_spacing">6dp</dimen>
<dimen name="toolbar_elevation">4dp</dimen>
</resources>

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,25 +14,29 @@
<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/>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 name="dark_mode">Dark Mode</string>
<string name="dismiss">Dismiss</string>
<string name="add_site">Add Site</string>
<string name="site_name">Site Name</string>
<string name="site_name_hint">Site display name</string>
<string name="site_url">Site URL</string>
<string name="site_url_hint">https://yoursite.com</string>
<string name="site_tags">Site Tags</string>
<string name="site_tags_hint">Site Tags (one, two, three)</string>
<string name="site_tags_hint">e.g. One,Two,Three</string>
<string name="site_tags_hint_full">Tags (e.g. One,Two,Three)</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_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_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_validCertUri">Certificate should be a valid file or content URI.</string>
<string name="options">Options</string>
<string name="remove_site">Remove Site</string>
<string name="duplicate_and_modify">Duplicate and Modify</string>
<string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string>
<string name="remove">Remove</string>
<string name="save_changes">Save Changes</string>
@ -46,8 +50,8 @@
<string name="disable_automatic_checks">Disable Automatic Validation</string>
<string name="disable_automatic_checks_prompt"><![CDATA[
Disable automatic validation for <b>%1$s</b>? The site will not be checked in the background
until you re-enable validation for it. You can still manually perform validation by tapping the
Refresh icon at the top of this page.
until you re-enable validation by tapping the checkmark (Save) icon. You can still manually
perform validation by tapping the Refresh icon at the top of this page.
]]></string>
<string name="disable">Disable</string>
<string name="renable_and_save_changes">Enable Auto Validation &amp; Save Changes</string>
@ -55,6 +59,10 @@
<string name="response_timeout">Network Response Timeout (ms)</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="warning_http_url">
@ -77,14 +85,6 @@
exception to pass custom error messages to Nock Nock.
</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>
</resources>

View file

@ -4,15 +4,9 @@
<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">
<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>

View file

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

View file

@ -6,4 +6,28 @@
<item name="android:textColor">?toolbarTitleColor</item>
</style>
<style name="InputForm"/>
<style name="InputForm.Header" parent="NockText.SectionHeader">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/content_inset_less</item>
</style>
<style name="InputForm.Field" parent="NockText.Body">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/content_inset_quarter</item>
<item name="android:singleLine">true</item>
<item name="android:imeOptions">actionNext</item>
<item name="android:layout_marginStart">-4dp</item>
<item name="android:layout_marginEnd">-4dp</item>
</style>
<style name="InputForm.FieldNote" parent="NockText.Footnote">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/list_text_spacing</item>
</style>
</resources>

View file

@ -19,11 +19,13 @@ import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
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.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
@ -55,7 +57,8 @@ fun fakeSettingsModel(
validationMode = validationMode,
validationArgs = null,
disabled = false,
networkTimeout = 10000
networkTimeout = 10000,
certificate = null
)
fun fakeResultModel(
@ -79,19 +82,31 @@ fun fakeRetryPolicy(
minutes = minutes
)
fun fakeModel(id: Long) = Site(
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,
tags: String = ""
) = Site(
id = id,
name = "Test",
url = "https://test.com",
tags = "",
tags = tags,
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(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 MOCK_MODEL_1 = fakeModel(1, tags = "one,two")
val MOCK_MODEL_2 = fakeModel(2, tags = "three,four")
val MOCK_MODEL_3 = fakeModel(3, tags = "five,six")
val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3)
fun mockDatabase(): AppDatabase {
@ -156,12 +171,29 @@ fun mockDatabase(): AppDatabase {
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

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

View file

@ -20,7 +20,7 @@ import com.afollestad.nocknock.ALL_MOCK_MODELS
import com.afollestad.nocknock.MOCK_MODEL_1
import com.afollestad.nocknock.MOCK_MODEL_2
import com.afollestad.nocknock.MOCK_MODEL_3
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.livedata.test
@ -39,7 +39,7 @@ class MainViewModelTest {
private val database = mockDatabase()
private val notificationManager = mock<NockNotificationManager>()
private val validationManager = mock<ValidationManager>()
private val validationManager = mock<ValidationExecutor>()
@Rule @JvmField val rule = InstantTaskExecutorRule()
@ -60,18 +60,45 @@ class MainViewModelTest {
.test()
val sites = viewModel.onSites()
.test()
val tags = viewModel.onTags()
.test()
val tagsVisibility = viewModel.onTagsListVisibility()
.test()
viewModel.onResume()
verify(notificationManager).cancelStatusNotifications()
verify(validationManager).ensureScheduledChecks()
verify(validationManager).ensureScheduledValidations()
sites.assertValues(
listOf(),
ALL_MOCK_MODELS
)
sites.assertValues(ALL_MOCK_MODELS)
isLoading.assertValues(true, 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() {
@ -86,10 +113,7 @@ class MainViewModelTest {
.test()
viewModel.onResume()
sites.assertValues(
listOf(),
ALL_MOCK_MODELS
)
sites.assertValues(ALL_MOCK_MODELS)
val updatedModel2 = MOCK_MODEL_2.copy(
name = "Wakanda Forever!!!"
@ -106,7 +130,7 @@ class MainViewModelTest {
@Test fun refreshSite() {
viewModel.refreshSite(MOCK_MODEL_3)
verify(validationManager).scheduleCheck(
verify(validationManager).scheduleValidation(
site = MOCK_MODEL_3,
rightNow = true,
cancelPrevious = true
@ -120,10 +144,7 @@ class MainViewModelTest {
.test()
viewModel.onResume()
sites.assertValues(
listOf(),
ALL_MOCK_MODELS
)
sites.assertValues(ALL_MOCK_MODELS)
isLoading.assertValues(true, false)
val modifiedModel = MOCK_MODEL_1.copy(id = 11111)
@ -132,7 +153,7 @@ class MainViewModelTest {
sites.assertNoValues()
isLoading.assertValues(true, false)
verify(validationManager).cancelCheck(modifiedModel)
verify(validationManager).cancelScheduledValidation(modifiedModel)
verify(notificationManager).cancelStatusNotification(modifiedModel)
verify(database.siteDao()).delete(modifiedModel)
verify(database.siteSettingsDao()).delete(modifiedModel.settings!!)
@ -147,10 +168,7 @@ class MainViewModelTest {
.test()
viewModel.onResume()
sites.assertValues(
listOf(),
ALL_MOCK_MODELS
)
sites.assertValues(ALL_MOCK_MODELS)
isLoading.assertValues(true, false)
val modelsWithout1 = ALL_MOCK_MODELS.toMutableList()
@ -163,7 +181,7 @@ class MainViewModelTest {
isLoading.assertValues(true, false)
emptyTextVisibility.assertValues(false, false, false)
verify(validationManager).cancelCheck(MOCK_MODEL_1)
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
verify(database.siteDao()).delete(MOCK_MODEL_1)
verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)

View file

@ -18,6 +18,8 @@ package com.afollestad.nocknock.ui.viewsite
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.afollestad.nocknock.MOCK_MODEL_1
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status.CHECKING
@ -28,7 +30,8 @@ import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.fakeRetryPolicy
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.livedata.test
@ -38,9 +41,10 @@ import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
@ -75,7 +79,7 @@ class ViewSiteViewModelTest {
}
}
private val database = mockDatabase()
private val validationManager = mock<ValidationManager>()
private val validationManager = mock<ValidationExecutor>()
private val notificationManager = mock<NockNotificationManager>()
@Rule @JvmField val rule = InstantTaskExecutorRule()
@ -255,247 +259,11 @@ class ViewSiteViewModelTest {
.isEqualTo("Two")
}
@Test fun commit_nameError() {
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
fillInModel().apply {
name.value = ""
}
val onDone = mock<() -> Unit>()
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(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())
.scheduleCheck(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())
.scheduleCheck(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())
.scheduleCheck(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())
.scheduleCheck(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())
.scheduleCheck(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())
.scheduleCheck(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertValues(R.string.please_enter_javaScript)
verify(onDone, never()).invoke()
}
@Test fun commit_success() = runBlocking {
whenever(database.retryPolicyDao().forSite(any())).doReturn(listOf(fakeRetryPolicy(1)))
val isLoading = viewModel.onIsLoading()
.test()
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
fillInModel()
val onDone = mock<() -> Unit>()
@ -506,11 +274,13 @@ class ViewSiteViewModelTest {
val siteCaptor = argumentCaptor<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>()
val resultCaptor = argumentCaptor<ValidationResult>()
val retryPolicyCaptor = argumentCaptor<RetryPolicy>()
isLoading.assertValues(true, false)
verify(database.siteDao()).update(siteCaptor.capture())
verify(database.siteSettingsDao()).update(settingsCaptor.capture())
verify(database.validationResultsDao()).update(resultCaptor.capture())
verify(database.retryPolicyDao()).update(retryPolicyCaptor.capture())
// From fillInModel() below
val updatedSettings = MOCK_MODEL_1.settings!!.copy(
@ -523,31 +293,26 @@ class ViewSiteViewModelTest {
val updatedResult = MOCK_MODEL_1.lastResult!!.copy(
status = WAITING
)
val retryPolicy = retryPolicyCaptor.firstValue
val updatedModel = MOCK_MODEL_1.copy(
name = "Hello There",
url = "https://www.hellothere.com",
settings = updatedSettings,
lastResult = updatedResult
lastResult = updatedResult,
retryPolicy = retryPolicy
)
assertThat(siteCaptor.firstValue).isEqualTo(updatedModel)
assertThat(settingsCaptor.firstValue).isEqualTo(updatedSettings)
assertThat(resultCaptor.firstValue).isEqualTo(updatedResult)
verify(validationManager).scheduleCheck(
verify(validationManager).scheduleValidation(
site = updatedModel,
rightNow = true,
cancelPrevious = true,
fromFinishingJob = false
)
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone).invoke()
}
@ -562,7 +327,7 @@ class ViewSiteViewModelTest {
)
viewModel.checkNow()
verify(validationManager).scheduleCheck(
verify(validationManager).scheduleValidation(
site = expectedModel,
rightNow = true,
cancelPrevious = true
@ -579,7 +344,7 @@ class ViewSiteViewModelTest {
viewModel.removeSite(onDone)
isLoading.assertValues(true, false)
verify(validationManager).cancelCheck(MOCK_MODEL_1)
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
verify(database.siteDao()).delete(MOCK_MODEL_1)
verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
@ -603,7 +368,7 @@ class ViewSiteViewModelTest {
)
)
verify(validationManager).cancelCheck(MOCK_MODEL_1)
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
verify(database.siteDao()).update(expectedSite)
verify(database.siteSettingsDao()).update(expectedSite.settings!!)
@ -619,5 +384,12 @@ class ViewSiteViewModelTest {
validationScript.value = "throw 'Oh no!'"
checkIntervalValue.value = 24
checkIntervalUnit.value = 60000
tags.value = "one,two"
retryPolicyTimes.value = 5
retryPolicyMinutes.value = 5
headers.value = listOf(
Header(2L, 1L, key = "Content-Type", value = "text/html"),
Header(3L, 1L, key = "User-Agent", value = "NockNock")
)
}
}

BIN
art/showcase5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 KiB

View file

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

View file

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

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 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 formerEnd = min(selectionEnd, text.length)
setText(text)

View file

@ -30,6 +30,8 @@ interface CanNotifyModel : Serializable {
fun notifyName(): String
fun notifyTag(): String
fun notifyDescription(): String?
}
/** @author Aidan Follestad (@afollestad) */

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import android.content.Context
import androidx.room.Room.inMemoryDatabaseBuilder
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.runner.AndroidJUnit4
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
@ -47,6 +48,7 @@ class AppDatabaseTest() {
private lateinit var settingsDao: SiteSettingsDao
private lateinit var resultsDao: ValidationResultsDao
private lateinit var retryDao: RetryPolicyDao
private lateinit var headerDao: HeaderDao
@Before fun setup() {
val context = getApplicationContext<Context>()
@ -55,6 +57,7 @@ class AppDatabaseTest() {
settingsDao = db.siteSettingsDao()
resultsDao = db.validationResultsDao()
retryDao = db.retryPolicyDao()
headerDao = db.headerDao()
}
@After
@ -70,7 +73,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0)
@ -81,7 +85,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1)
@ -99,7 +104,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId = sitesDao.insert(model)
assertThat(newId).isGreaterThan(0)
@ -115,7 +121,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId = sitesDao.insert(initialModel)
assertThat(newId).isGreaterThan(0)
@ -140,7 +147,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0)
@ -151,7 +159,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1)
@ -172,7 +181,8 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE,
validationArgs = null,
disabled = false,
networkTimeout = 10000
networkTimeout = 10000,
certificate = null
)
val newId = settingsDao.insert(model)
assertThat(newId).isEqualTo(1)
@ -190,7 +200,8 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE,
validationArgs = null,
disabled = false,
networkTimeout = 10000
networkTimeout = 10000,
certificate = null
)
)
@ -218,7 +229,8 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE,
validationArgs = null,
disabled = false,
networkTimeout = 10000
networkTimeout = 10000,
certificate = null
)
)
@ -338,6 +350,78 @@ class AppDatabaseTest() {
assertThat(retryDao.forSite(1)).isEmpty()
}
// HeaderDao
@Test fun headers_insert_and_forSite() {
val models = listOf(
Header(
siteId = 1,
key = "Name",
value = "Aidan"
),
Header(
siteId = 1,
key = "Born",
value = "1995"
)
)
val newIds = headerDao.insert(models)
assertThat(newIds.first()).isEqualTo(1)
assertThat(newIds.last()).isEqualTo(2)
val finalModels = headerDao.forSite(1)
assertThat(finalModels.first()).isEqualTo(models.first().copy(id = 1))
assertThat(finalModels.last()).isEqualTo(models.last().copy(id = 2))
}
@Test fun headers_update() {
val models = listOf(
Header(
siteId = 1,
key = "Name",
value = "Aidan"
),
Header(
siteId = 1,
key = "Born",
value = "1995"
)
)
headerDao.insert(models)
val insertedModel = headerDao.forSite(1)
.last()
val updatedModel = insertedModel.copy(
key = "Test",
value = "Hello"
)
assertThat(headerDao.update(updatedModel)).isEqualTo(1)
val finalModels = headerDao.forSite(1)
assertThat(finalModels.first()).isEqualTo(models.first().copy(id = 1))
assertThat(finalModels.last()).isEqualTo(updatedModel)
}
@Test fun headers_delete() {
val models = listOf(
Header(
siteId = 1,
key = "Name",
value = "Aidan"
),
Header(
siteId = 1,
key = "Born",
value = "1995"
)
)
headerDao.insert(models)
val insertedModels = headerDao.forSite(1)
headerDao.delete(insertedModels)
assertThat(headerDao.forSite(1)).isEmpty()
}
// Extension Methods
@Test fun extension_put_and_allSites() {
@ -347,28 +431,30 @@ class AppDatabaseTest() {
val allSites = db.allSites()
assertThat(allSites.size).isEqualTo(3)
assertThat(allSites[0]).isEqualTo(MOCK_MODEL_1)
assertThat(allSites[1]).isEqualTo(MOCK_MODEL_2)
assertThat(allSites[2]).isEqualTo(MOCK_MODEL_3)
}
@Test fun extension_put_and_allSites_withTag() {
val model1 = MOCK_MODEL_1.copy(tags = "one,two,three")
val model2 = MOCK_MODEL_2.copy(tags = "four,five,six")
val model3 = MOCK_MODEL_3.copy(tags = "seven,eight,nine")
db.putSite(model1)
db.putSite(model2)
db.putSite(model3)
val allSites1 = db.allSites(forTag = "one")
assertThat(allSites1.single()).isEqualTo(model1)
val allSites2 = db.allSites(forTag = "five")
assertThat(allSites2.single()).isEqualTo(model2)
val allSites3 = db.allSites(forTag = "nine")
assertThat(allSites3.single()).isEqualTo(model3)
assertThat(allSites[0]).isEqualTo(
MOCK_MODEL_1.copy(
headers = listOf(
MOCK_MODEL_1.headers.first().copy(id = 1),
MOCK_MODEL_1.headers.last().copy(id = 2)
)
)
)
assertThat(allSites[1]).isEqualTo(
MOCK_MODEL_2.copy(
headers = listOf(
MOCK_MODEL_2.headers.first().copy(id = 3),
MOCK_MODEL_2.headers.last().copy(id = 4)
)
)
)
assertThat(allSites[2]).isEqualTo(
MOCK_MODEL_3.copy(
headers = listOf(
MOCK_MODEL_3.headers.first().copy(id = 5),
MOCK_MODEL_3.headers.last().copy(id = 6)
)
)
)
}
@Test fun extension_put_getSite() {
@ -403,12 +489,25 @@ class AppDatabaseTest() {
count = 4,
minutes = 8
)
val updatedHeaders = listOf(
modelToUpdate.headers.first().copy(
id = 7,
key = "One",
value = "Hello"
),
modelToUpdate.headers.last().copy(
id = 8,
key = "Two",
value = "Hey"
)
)
val updatedModel = modelToUpdate.copy(
name = "Oijrfouhef",
url = "https://iojfdfsdk.io",
settings = updatedSettings,
lastResult = updatedValidationResult,
retryPolicy = updatedRetryPolicy
retryPolicy = updatedRetryPolicy,
headers = updatedHeaders
)
db.updateSite(updatedModel)
@ -417,6 +516,8 @@ class AppDatabaseTest() {
assertThat(finalSite.settings).isEqualTo(updatedSettings)
assertThat(finalSite.lastResult).isEqualTo(updatedValidationResult)
assertThat(finalSite.retryPolicy).isEqualTo(updatedRetryPolicy)
assertThat(finalSite.headers.first()).isEqualTo(updatedHeaders.first())
assertThat(finalSite.headers.last()).isEqualTo(updatedHeaders.last())
assertThat(finalSite).isEqualTo(updatedModel)
}
@ -426,7 +527,7 @@ class AppDatabaseTest() {
db.putSite(MOCK_MODEL_3)
val allSites = db.allSites()
db.deleteSite(MOCK_MODEL_2)
db.deleteSite(allSites[1])
val remainingSettings = settingsDao.all()
assertThat(remainingSettings.size).isEqualTo(2)
@ -442,5 +543,12 @@ class AppDatabaseTest() {
assertThat(remainingRetryPolicies.size).isEqualTo(2)
assertThat(remainingRetryPolicies[0]).isEqualTo(allSites[0].retryPolicy!!)
assertThat(remainingRetryPolicies[1]).isEqualTo(allSites[2].retryPolicy!!)
val remainingHeaders = headerDao.all()
assertThat(remainingHeaders.size).isEqualTo(4)
assertThat(remainingHeaders[0]).isEqualTo(allSites[0].headers.first())
assertThat(remainingHeaders[1]).isEqualTo(allSites[0].headers.last())
assertThat(remainingHeaders[2]).isEqualTo(allSites[2].headers.first())
assertThat(remainingHeaders[3]).isEqualTo(allSites[2].headers.last())
}
}

View file

@ -15,6 +15,7 @@
*/
package com.afollestad.nocknock.data
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
@ -34,7 +35,8 @@ fun fakeSettingsModel(
validationMode = validationMode,
validationArgs = null,
disabled = false,
networkTimeout = 10000
networkTimeout = 10000,
certificate = null
)
fun fakeResultModel(
@ -58,6 +60,11 @@ fun fakeRetryPolicy(
minutes = minutes
)
fun fakeHeaders(siteId: Long) = listOf(
Header(siteId = siteId, key = "Content-Type", value = "text/html"),
Header(siteId = siteId, key = "User-Agent", value = "NockNock")
)
fun fakeModel(id: Long) = Site(
id = id,
name = "Test",
@ -65,7 +72,8 @@ fun fakeModel(id: Long) = Site(
tags = "",
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id)
retryPolicy = fakeRetryPolicy(id),
headers = fakeHeaders(id)
)
val MOCK_MODEL_1 = fakeModel(1)

View file

@ -19,6 +19,7 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.afollestad.nocknock.data.model.Converters
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
@ -27,12 +28,13 @@ import com.afollestad.nocknock.data.model.ValidationResult
/** @author Aidan Follestad (@afollestad) */
@Database(
entities = [
Header::class,
RetryPolicy::class,
ValidationResult::class,
SiteSettings::class,
Site::class
],
version = 3,
version = 5,
exportSchema = false
)
@TypeConverters(Converters::class)
@ -45,6 +47,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun validationResultsDao(): ValidationResultsDao
abstract fun retryPolicyDao(): RetryPolicyDao
abstract fun headerDao(): HeaderDao
}
/**
@ -61,10 +65,12 @@ fun AppDatabase.allSites(): List<Site> {
.singleOrNull()
val retryPolicy = retryPolicyDao().forSite(it.id)
.singleOrNull()
val headers = headerDao().forSite(it.id)
return@map it.copy(
settings = settings,
lastResult = lastResult,
retryPolicy = retryPolicy
retryPolicy = retryPolicy,
headers = headers
)
}
}
@ -83,10 +89,12 @@ fun AppDatabase.getSite(id: Long): Site? {
.singleOrNull()
val retryPolicy = retryPolicyDao().forSite(id)
.singleOrNull()
val headers = headerDao().forSite(id)
return result.copy(
settings = settings,
lastResult = lastResult,
retryPolicy = retryPolicy
retryPolicy = retryPolicy,
headers = headers
)
}
@ -101,14 +109,19 @@ fun AppDatabase.putSite(site: Site): Site {
val settingsWithSiteId = settings.copy(siteId = newId)
val lastResultWithSiteId = site.lastResult?.copy(siteId = newId)
val retryPolicyWithSiteId = site.retryPolicy?.copy(siteId = newId)
siteSettingsDao().insert(settingsWithSiteId)
val headersWithSiteId = site.headers.map { it.copy(siteId = newId) }
siteSettingsDao().insert(settingsWithSiteId)
lastResultWithSiteId?.let { validationResultsDao().insert(it) }
retryPolicyWithSiteId?.let { retryPolicyDao().insert(it) }
headerDao().insert(headersWithSiteId)
return site.copy(
id = newId,
settings = settingsWithSiteId
settings = settingsWithSiteId,
lastResult = lastResultWithSiteId,
retryPolicy = retryPolicyWithSiteId,
headers = headersWithSiteId
)
}
@ -152,6 +165,13 @@ fun AppDatabase.updateSite(site: Site) {
retryPolicyDao().insert(retryPolicy)
}
}
// Wipe existing headers
headerDao().delete(headerDao().forSite(site.id))
// Then add ones that still exist
site.headers.forEach { header ->
headerDao().insert(header.copy(id = 0, siteId = site.id))
}
}
/**
@ -163,5 +183,9 @@ fun AppDatabase.deleteSite(site: Site) {
site.settings?.let { siteSettingsDao().delete(it) }
site.lastResult?.let { validationResultsDao().delete(it) }
site.retryPolicy?.let { retryPolicyDao().delete(it) }
if (site.headers.any { it.id == 0L }) {
throw IllegalStateException("Cannot delete header with ID = 0.")
}
headerDao().delete(site.headers)
siteDao().delete(site)
}

View file

@ -27,7 +27,7 @@ class Database1to2Migration : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes INTEGER NOT NULL, lastTryTimestamp INTEGER NOT NULL, triesLeft INTEGER NOT NULL)"
"CREATE TABLE IF NOT EXISTS `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes INTEGER NOT NULL, lastTryTimestamp INTEGER NOT NULL, triesLeft INTEGER NOT NULL)"
)
}
}
@ -43,3 +43,29 @@ class Database2to3Migration : Migration(2, 3) {
database.execSQL("ALTER TABLE `sites` ADD COLUMN tags TEXT NOT NULL DEFAULT ''")
}
}
/**
* Migrates the database from version 3 to 4.
*
* @author Aidan Follestad (@afollestad)
*/
class Database3to4Migration : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `headers` (id INTEGER PRIMARY KEY NOT NULL, siteId INTEGER NOT NULL, `key` TEXT NOT NULL, value TEXT NOT NULL)"
)
}
}
/**
* 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

@ -0,0 +1,47 @@
/**
* 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.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy.FAIL
import androidx.room.Query
import androidx.room.Update
import com.afollestad.nocknock.data.model.Header
/** @author Aidan Follestad (@afollestad) */
@Dao
interface HeaderDao {
@Query("SELECT * FROM headers ORDER BY siteId ASC")
fun all(): List<Header>
@Query("SELECT * FROM headers WHERE siteId = :siteId")
fun forSite(siteId: Long): List<Header>
@Insert(onConflict = FAIL)
fun insert(headers: Header): Long
@Insert(onConflict = FAIL)
fun insert(headers: List<Header>): List<Long>
@Update(onConflict = FAIL)
fun update(header: Header): Int
@Delete
fun delete(headers: List<Header>): Int
}

View file

@ -0,0 +1,42 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("unused")
package com.afollestad.nocknock.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
/**
* Represents an HTTP header that is sent with a site's validation attempts.
*
* @author Aidan Follestad (@afollestad)
*/
@Entity(tableName = "headers")
data class Header(
/** The header's unique datrabase ID. */
@PrimaryKey(autoGenerate = true) var id: Long = 0,
/** The [Site] this header belong to. */
var siteId: Long = 0,
/** The header key/name. */
var key: String = "",
/** The header value. */
var value: String = ""
) : Serializable {
constructor() : this(0, 0, "", "")
}

View file

@ -57,6 +57,14 @@ data class RetryPolicy(
return -1
}
val timesPerMinute = count.toFloat() / minutes.toFloat()
return MINUTE / timesPerMinute.toInt()
return MINUTE / timesPerMinute.toSafeInt()
}
private fun Float.toSafeInt(): Int {
val intValue = toInt()
if (intValue == 0) {
return 1
}
return intValue
}
}

View file

@ -40,10 +40,12 @@ data class Site(
/** The last validation attempt result for the site, if any. */
@Ignore var lastResult: ValidationResult?,
/** The site's retry policy, if any. */
@Ignore var retryPolicy: RetryPolicy?
@Ignore var retryPolicy: RetryPolicy?,
/** Request headers sent with this site's validation attempts. */
@Ignore var headers: List<Header>
) : CanNotifyModel {
constructor() : this(0, "", "", "", null, null, null)
constructor() : this(0, "", "", "", null, null, null, emptyList())
override fun notifyId(): Int = id.toInt()
@ -51,6 +53,8 @@ data class Site(
override fun notifyTag(): String = url
override fun notifyDescription() = lastResult?.reason
fun intervalText(): String {
requireNotNull(settings) { "Settings not queried." }
val lastCheck = lastResult?.timestampMs ?: -1

View file

@ -40,8 +40,10 @@ data class SiteSettings(
/** Whether or not the [Site] is enabled for automatic periodic checks. */
var disabled: Boolean,
/** The network response timeout for validation attempts. */
var networkTimeout: Int
var networkTimeout: Int,
/** The Uri to a self signed certificate. */
var certificate: String?
) : 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,
compileSdk : 28,
buildTools : '28.0.3',
publishVersion : '0.8.3',
publishVersionCode : 35,
publishVersion : '0.8.8',
publishVersionCode : 46,
// Plugins
gradlePlugin : '3.2.1',
spotlessPlugin : '3.17.0',
versionPlugin : '0.20.0',
gradlePlugin : '3.4.0',
spotlessPlugin : '3.22.0',
versionPlugin : '0.21.0',
googleServices : '4.2.0',
fabricPlugin : '1.+',
// Misc
okHttp : '3.12.1',
okHttp : '3.14.1',
rhino : '1.7.10',
// Kotlin
kotlin : '1.3.11',
coroutines : '1.1.0',
kotlin : '1.3.30',
coroutines : '1.2.0',
koin : '1.0.2',
// Google/AndroidX
androidxAnnotations : '1.0.1',
androidxAnnotations : '1.0.2',
androidxCore : '1.0.2',
androidxRecyclerView: '1.0.0',
androidxBrowser : '1.0.0',
googleMaterial : '1.0.0',
room : '2.0.0',
lifecycle : '2.0.0',
firebaseCore : '16.0.8',
// Rx
rxJava : '2.2.8',
rxBinding : '3.0.0-alpha1',
// afollestad
materialDialogs : '2.0.0-rc7',
rxkPrefs : '1.2.1',
materialDialogs : '2.8.1',
rxkPrefs : '1.2.5',
vvalidator : '0.4.1',
// Debugging
timber : '4.7.1',
fabric : '2.9.8@aar',
fabric : '2.9.9@aar',
// Unit testing
junit : '4.12',
mockito : '2.23.4',
mockitoKotlin : '2.0.0-RC1',
truth : '0.42',
mockito : '2.27.0',
mockitoKotlin : '2.1.0',
truth : '0.44',
// UI testing
androidxTestRunner : '1.1.1',
androidxTest : '1.1.0',
archTesting : '2.0.0'
archTesting : '2.0.1'
]

View file

@ -15,14 +15,18 @@
*/
package com.afollestad.nocknock.engine
import com.afollestad.nocknock.engine.validation.RealValidationManager
import com.afollestad.nocknock.engine.validation.ValidationManager
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.ValidationExecutor
import org.koin.dsl.module.module
/** @author Aidan Follestad (@afollestad) */
val engineModule = module {
single {
RealValidationManager(get(), get(), get(), get(), get(), get())
} bind ValidationManager::class
RealValidationExecutor(get(), get(), get(), get(), get(), get(), get())
} 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

@ -32,7 +32,7 @@ import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */
class BootReceiver : BroadcastReceiver(), KoinComponent {
private val validationManager by inject<ValidationManager>()
private val validationManager by inject<ValidationExecutor>()
private val mainDispatcher by inject<CoroutineDispatcher>(name = MAIN_DISPATCHER)
private val ioDispatcher by inject<CoroutineDispatcher>(name = IO_DISPATCHER)
@ -48,7 +48,7 @@ class BootReceiver : BroadcastReceiver(), KoinComponent {
val pendingResult = goAsync()
GlobalScope.launch(mainDispatcher) {
withContext(ioDispatcher) { validationManager.ensureScheduledChecks() }
withContext(ioDispatcher) { validationManager.ensureScheduledValidations() }
pendingResult.resultCode = 0
pendingResult.finish()
}

View file

@ -17,21 +17,26 @@ package com.afollestad.nocknock.engine.validation
import android.app.job.JobScheduler
import android.app.job.JobScheduler.RESULT_SUCCESS
import android.net.Uri
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.allSites
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.ERROR
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.engine.R
import com.afollestad.nocknock.engine.ssl.SslManager
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID
import com.afollestad.nocknock.utilities.ext.isNotNullOrEmpty
import com.afollestad.nocknock.utilities.providers.BundleProvider
import com.afollestad.nocknock.utilities.providers.JobInfoProvider
import com.afollestad.nocknock.utilities.providers.StringProvider
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jetbrains.annotations.TestOnly
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit.MILLISECONDS
import kotlin.math.max
import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */
@ -42,12 +47,14 @@ data class CheckResult(
typealias ClientTimeoutChanger = (client: OkHttpClient, timeout: Int) -> OkHttpClient
typealias UriConverter = (String) -> Uri
/** @author Aidan Follestad (@afollestad) */
interface ValidationManager {
interface ValidationExecutor {
suspend fun ensureScheduledChecks()
suspend fun ensureScheduledValidations()
fun scheduleCheck(
fun scheduleValidation(
site: Site,
rightNow: Boolean = false,
cancelPrevious: Boolean = rightNow,
@ -55,19 +62,20 @@ interface ValidationManager {
overrideDelay: Long = -1
)
fun cancelCheck(site: Site)
fun cancelScheduledValidation(site: Site)
suspend fun performCheck(site: Site): CheckResult
suspend fun performValidation(site: Site): CheckResult
}
class RealValidationManager(
class RealValidationExecutor(
private val jobScheduler: JobScheduler,
private val okHttpClient: OkHttpClient,
private val stringProvider: StringProvider,
private val bundleProvider: BundleProvider,
private val jobInfoProvider: JobInfoProvider,
private val database: AppDatabase
) : ValidationManager {
private val database: AppDatabase,
private val sslManager: SslManager
) : ValidationExecutor {
private var clientTimeoutChanger: ClientTimeoutChanger = { client, timeout ->
client.newBuilder()
@ -75,37 +83,37 @@ class RealValidationManager(
.build()
}
override suspend fun ensureScheduledChecks() {
override suspend fun ensureScheduledValidations() {
val sites = database.allSites()
if (sites.isEmpty()) {
return
}
log("Ensuring enabled sites have scheduled checks.")
log("Ensuring enabled sites have scheduled validations.")
sites.filter { it.settings?.disabled != true }
.forEach { site ->
val existingJob = jobForSite(site)
if (existingJob == null) {
log("Site ${site.id} does NOT have a scheduled job, running one now.")
scheduleCheck(site = site, rightNow = true)
scheduleValidation(site = site, rightNow = true)
} else {
log("Site ${site.id} already has a scheduled job. Nothing to do.")
}
}
}
override fun scheduleCheck(
override fun scheduleValidation(
site: Site,
rightNow: Boolean,
cancelPrevious: Boolean,
fromFinishingJob: Boolean,
overrideDelay: Long
) {
check(site.id != 0L) { "Cannot schedule checks for jobs with no ID." }
check(site.id != 0L) { "Cannot schedule validations for jobs with no ID." }
val siteSettings = site.settings
requireNotNull(siteSettings) { "Site settings must be populated." }
if (cancelPrevious) {
cancelCheck(site)
cancelScheduledValidation(site)
} else if (!fromFinishingJob) {
val existingJob = jobForSite(site)
check(existingJob == null) {
@ -113,7 +121,7 @@ class RealValidationManager(
}
}
log("Requesting a check job for site to be scheduled: $site")
log("Requesting a validation job for site to be scheduled: $site")
val extras = bundleProvider.createPersistable {
putLong(KEY_SITE_ID, site.id)
}
@ -131,43 +139,59 @@ class RealValidationManager(
val dispatchResult = jobScheduler.schedule(jobInfo)
if (dispatchResult != RESULT_SUCCESS) {
log("Failed to schedule a check job for site: ${site.id}")
log("Failed to schedule a validation job for site: ${site.id}")
} else {
log("Check job successfully scheduled for site: ${site.id}")
log("Validation job successfully scheduled for site: ${site.id}")
}
}
override fun cancelCheck(site: Site) {
check(site.id != 0L) { "Cannot cancel scheduled checks for jobs with no ID." }
log("Cancelling scheduled checks for site: ${site.id}")
override fun cancelScheduledValidation(site: Site) {
check(site.id != 0L) { "Cannot cancel scheduled validations for jobs with no ID." }
log("Cancelling scheduled validations for site: ${site.id}")
jobScheduler.cancel(site.id.toInt())
}
override suspend fun performCheck(site: Site): CheckResult {
check(site.id != 0L) { "Cannot schedule checks for jobs with no ID." }
override suspend fun performValidation(site: Site): CheckResult {
check(site.id != 0L) { "Cannot schedule validations for jobs with no ID." }
val siteSettings = site.settings
requireNotNull(siteSettings) { "Site settings must be populated." }
check(siteSettings.networkTimeout > 0) { "Network timeout not set for site ${site.id}" }
log("performCheck(${site.id}) - GET ${site.url}")
log("performValidation(${site.id}) - GET ${site.url}")
val request = Request.Builder()
.url(site.url)
.get()
.apply {
url(site.url)
get()
site.headers
.filter { header -> header.key.isNotNullOrEmpty() }
.forEach { header ->
addHeader(header.key, header.value)
}
}
.build()
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)
.execute()
if (response.isSuccessful || response.code() == 401) {
log("performCheck(${site.id}) = Successful")
if (response.isSuccessful) {
log("performValidation(${site.id}) = Successful")
CheckResult(
model = site.withStatus(status = OK, reason = null),
response = response
)
} else {
log("performCheck(${site.id}) = Failure, HTTP code ${response.code()}")
log("performValidation(${site.id}) = Failure, HTTP code ${response.code()}")
CheckResult(
model = site.withStatus(
status = ERROR,
@ -177,7 +201,7 @@ class RealValidationManager(
)
}
} catch (timeoutEx: SocketTimeoutException) {
log("performCheck(${site.id}) = Socket Timeout")
log("performValidation(${site.id}) = Socket Timeout")
CheckResult(
model = site.withStatus(
status = ERROR,
@ -185,7 +209,8 @@ class RealValidationManager(
)
)
} catch (ex: Exception) {
log("performCheck(${site.id}) = Error: ${ex.message}")
ex.printStackTrace()
log("performValidation(${site.id}) = Error: ${ex.message}")
CheckResult(model = site.withStatus(status = ERROR, reason = ex.message))
}
}
@ -194,7 +219,7 @@ class RealValidationManager(
jobScheduler.allPendingJobs
.firstOrNull { job -> job.id == site.id.toInt() }
// @TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) {
// this.clientTimeoutChanger = changer
// }
@TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) {
this.clientTimeoutChanger = changer
}
}

View file

@ -19,8 +19,8 @@ import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Intent
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.getSite
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.Status.CHECKING
@ -60,7 +60,7 @@ class ValidationJob : JobService() {
}
private val database by inject<AppDatabase>()
private val validationManager by inject<ValidationManager>()
private val validationManager by inject<ValidationExecutor>()
private val notificationManager by inject<NockNotificationManager>()
override fun onStartJob(params: JobParameters): Boolean {
@ -80,10 +80,14 @@ class ValidationJob : JobService() {
sendBroadcast(Intent(ACTION_JOB_RUNNING).apply { putExtra(KEY_SITE_ID, site.id) })
log("Checking ${site.name} (${site.url})...")
val lastResult = site.lastResult
if (lastResult != null) {
log("Result of previous attempt: ${lastResult.status}")
}
val jobResult = async(IO) {
updateStatus(site, CHECKING)
val checkResult = validationManager.performCheck(site)
val checkResult = validationManager.performValidation(site)
val resultModel = checkResult.model
val resultResponse = checkResult.response
val result = resultModel.lastResult!!
@ -139,6 +143,9 @@ class ValidationJob : JobService() {
if (jobResult.lastResult!!.status == OK) {
notificationManager.cancelStatusNotification(jobResult)
if (lastResult != null && lastResult.status == ERROR) {
notificationManager.postValidationSuccessNotification(jobResult)
}
} else {
val retryPolicy = site.retryPolicy
if (retryPolicy != null) {
@ -153,7 +160,7 @@ class ValidationJob : JobService() {
updateTriesLeft(retryPolicy, retryPolicy.triesLeft)
val interval = retryPolicy.interval()
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = jobResult,
fromFinishingJob = true,
overrideDelay = interval
@ -167,10 +174,10 @@ class ValidationJob : JobService() {
}
}
notificationManager.postStatusNotification(jobResult)
notificationManager.postValidationErrorNotification(jobResult)
}
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = jobResult,
fromFinishingJob = true
)

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.content.ComponentName
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.IBundle
import com.afollestad.nocknock.utilities.providers.IBundler
@ -34,11 +36,11 @@ fun testBundleProvider(): BundleProvider {
val realBundle = mock<PersistableBundle>()
val creator = it.getArgument<IBundler>(0)
creator(object : IBundle {
override fun putInt(
override fun putLong(
key: String,
value: Int
value: Long
) {
whenever(realBundle.getInt(key)).doReturn(value)
whenever(realBundle.getLong(key)).doReturn(value)
}
})
return@doAnswer realBundle
@ -66,3 +68,21 @@ fun testJobInfoProvider(): JobInfoProvider {
}
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.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.OK
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.legacy.ServerModelStore
import com.afollestad.nocknock.engine.ssl.SslManager
import com.afollestad.nocknock.engine.validation.RealValidationExecutor
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID
import com.afollestad.nocknock.engine.validation.RealValidationManager
import com.afollestad.nocknock.utilities.providers.StringProvider
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
@ -45,7 +44,7 @@ import okhttp3.ResponseBody
import org.junit.Test
import java.net.SocketTimeoutException
class CheckStatusManagerTest {
class ValidationExecutorTest {
private val timeoutError = "Oh no, a timeout"
@ -56,15 +55,21 @@ class CheckStatusManagerTest {
}
private val bundleProvider = testBundleProvider()
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,
okHttpClient,
stringProvider,
bundleProvider,
jobInfoProvider,
store
database,
sslManager
).apply {
setClientTimeoutChanger { _, timeout ->
whenever(okHttpClient.callTimeoutMillis()).doReturn(timeout)
@ -72,202 +77,241 @@ class CheckStatusManagerTest {
}
}
@Test fun ensureScheduledChecks_noEnabledSites() = runBlocking {
val model1 = fakeModel().copy(disabled = true)
whenever(store.get()).doReturn(listOf(model1))
@Test fun ensureScheduledValidations_noEnabledSites() = runBlocking {
val model1 = fakeModel(id = 1)
model1.settings = model1.settings!!.copy(disabled = true)
database.setAllSites(model1)
manager.ensureScheduledChecks()
manager.ensureScheduledValidations()
verifyNoMoreInteractions(jobScheduler)
}
@Test fun ensureScheduledChecks_sitesAlreadyHaveJobs() = runBlocking<Unit> {
val model1 = fakeModel()
val job1 = fakeJob(model1.id)
whenever(store.get()).doReturn(listOf(model1))
@Test fun ensureScheduledValidations_sitesAlreadyHaveJobs() = runBlocking<Unit> {
val model1 = fakeModel(id = 1)
val job1 = fakeJob(1)
database.setAllSites(model1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
manager.ensureScheduledChecks()
manager.ensureScheduledValidations()
verify(jobScheduler, never()).schedule(any())
}
@Test fun ensureScheduledChecks() = runBlocking {
val model1 = fakeModel()
whenever(store.get()).doReturn(listOf(model1))
@Test fun ensureScheduledValidations() = runBlocking {
val model1 = fakeModel(id = 1)
database.setAllSites(model1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>())
manager.ensureScheduledChecks()
manager.ensureScheduledValidations()
val jobCaptor = argumentCaptor<JobInfo>()
verify(jobScheduler).schedule(jobCaptor.capture())
val jobInfo = jobCaptor.allValues.single()
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() {
val model1 = fakeModel()
@Test fun scheduleValidation_rightNow() {
val model1 = fakeModel(id = 1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>())
manager.scheduleCheck(
manager.scheduleValidation(
site = model1,
rightNow = true
)
val jobCaptor = argumentCaptor<JobInfo>()
verify(jobScheduler).schedule(jobCaptor.capture())
verify(jobScheduler).cancel(model1.id)
verify(jobScheduler).cancel(1)
val jobInfo = jobCaptor.allValues.single()
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)
fun scheduleCheck_notFromFinishingJob_haveExistingJob() {
val model1 = fakeModel()
val job1 = fakeJob(model1.id)
fun scheduleValidation_notFromFinishingJob_haveExistingJob() {
val model1 = fakeModel(id = 1)
val job1 = fakeJob(1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
manager.scheduleCheck(
manager.scheduleValidation(
site = model1,
fromFinishingJob = false
)
}
@Test fun scheduleCheck_fromFinishingJob_haveExistingJob() {
val model1 = fakeModel()
val job1 = fakeJob(model1.id)
@Test fun scheduleValidation_fromFinishingJob_haveExistingJob() {
val model1 = fakeModel(id = 1)
val job1 = fakeJob(1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
manager.scheduleCheck(
manager.scheduleValidation(
site = model1,
fromFinishingJob = true
)
val jobCaptor = argumentCaptor<JobInfo>()
verify(jobScheduler).schedule(jobCaptor.capture())
verify(jobScheduler, never()).cancel(model1.id)
verify(jobScheduler, never()).cancel(any())
val jobInfo = jobCaptor.allValues.single()
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() {
val model1 = fakeModel()
@Test fun scheduleValidation() {
val model1 = fakeModel(id = 1)
whenever(jobScheduler.allPendingJobs).doReturn(listOf<JobInfo>())
manager.scheduleCheck(
manager.scheduleValidation(
site = model1,
fromFinishingJob = true
)
val jobCaptor = argumentCaptor<JobInfo>()
verify(jobScheduler).schedule(jobCaptor.capture())
verify(jobScheduler, never()).cancel(model1.id)
verify(jobScheduler, never()).cancel(any())
val jobInfo = jobCaptor.allValues.single()
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() {
val model1 = fakeModel()
manager.cancelCheck(model1)
verify(jobScheduler).cancel(model1.id)
@Test fun cancelScheduledValidation() {
val model1 = fakeModel(id = 1)
manager.cancelScheduledValidation(model1)
verify(jobScheduler).cancel(1)
}
@Test fun performCheck_httpNotSuccess() = runBlocking {
@Test fun performValidation_httpNotSuccess() = runBlocking {
val response = fakeResponse(500, "Internal Server Error", "Hello World")
val call = mock<Call> {
on { execute() } doReturn response
}
whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel()
val result = manager.performCheck(model1)
val model1 = fakeModel(id = 1)
val result = manager.performValidation(model1)
assertThat(result.model).isEqualTo(
model1.copy(
status = ERROR,
reason = "Response 500 - Hello World"
lastResult = model1.lastResult?.copy(
status = ERROR,
reason = "Response 500 - Hello World"
)
)
)
}
@Test fun performCheck_socketTimeout() = runBlocking {
@Test fun performValidation_socketTimeout() = runBlocking {
val error = SocketTimeoutException("Oh no!")
val call = mock<Call> {
on { execute() } doAnswer { throw error }
}
whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel()
val result = manager.performCheck(model1)
val model1 = fakeModel(id = 1)
val result = manager.performValidation(model1)
assertThat(result.model).isEqualTo(
model1.copy(
status = ERROR,
reason = timeoutError
lastResult = model1.lastResult?.copy(
status = ERROR,
reason = timeoutError
)
)
)
}
@Test fun performCheck_exception() = runBlocking {
@Test fun performValidation_exception() = runBlocking {
val error = Exception("Oh no!")
val call = mock<Call> {
on { execute() } doAnswer { throw error }
}
whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel()
val result = manager.performCheck(model1)
val model1 = fakeModel(id = 1)
val result = manager.performValidation(model1)
assertThat(result.model).isEqualTo(
model1.copy(
status = ERROR,
reason = "Oh no!"
lastResult = model1.lastResult?.copy(
status = ERROR,
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 call = mock<Call> {
on { execute() } doReturn response
}
whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel()
val result = manager.performCheck(model1)
val model1 = fakeModel(id = 1).copy(
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(
model1.copy(
status = OK,
reason = null
lastResult = model1.lastResult?.copy(
status = OK,
reason = null
)
)
)
assertThat(okHttpClient.callTimeoutMillis())
.isEqualTo(model1.networkTimeout)
}
.isEqualTo(model1.settings!!.networkTimeout)
@Test fun performCheck_401_butStillSuccess() = runBlocking {
val response = fakeResponse(401, "Unauthorized", "Hello World")
val call = mock<Call> {
on { execute() } doReturn response
}
whenever(okHttpClient.newCall(any())).doReturn(call)
val model1 = fakeModel()
val result = manager.performCheck(model1)
assertThat(result.model).isEqualTo(
model1.copy(
status = OK,
reason = null
)
verify(sslManager).clientForCertificate(
"file:///sdcard/cert.pem",
"http://wwww.mysite.com/test.html",
okHttpClient
)
}
@ -293,14 +337,6 @@ class CheckStatusManagerTest {
.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 {
return mock {
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
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
android.useAndroidX=true
android.enableJetifier=true

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 27 KiB

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

@ -24,7 +24,13 @@ enum class Channel(
val description: Int,
val importance: Int
) {
CheckFailures(
ValidationSuccess(
id = "check_success",
title = R.string.channel_server_check_success_title,
description = R.string.channel_server_check_success_description,
importance = IMPORTANCE_DEFAULT
),
ValidationErrors(
id = "check_failures",
title = R.string.channel_server_check_failures_title,
description = R.string.channel_server_check_failures_description,

View file

@ -18,7 +18,7 @@ package com.afollestad.nocknock.notifications
import android.annotation.TargetApi
import android.app.NotificationManager
import android.os.Build.VERSION_CODES
import com.afollestad.nocknock.notifications.Channel.CheckFailures
import com.afollestad.nocknock.notifications.Channel.ValidationErrors
import com.afollestad.nocknock.utilities.providers.CanNotifyModel
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider
@ -34,7 +34,9 @@ interface NockNotificationManager {
fun createChannels()
fun postStatusNotification(model: CanNotifyModel)
fun postValidationErrorNotification(model: CanNotifyModel)
fun postValidationSuccessNotification(model: CanNotifyModel)
fun cancelStatusNotification(model: CanNotifyModel)
@ -60,26 +62,48 @@ class RealNockNotificationManager(
override fun createChannels() =
Channel.values().forEach(this::createChannel)
override fun postStatusNotification(model: CanNotifyModel) {
override fun postValidationErrorNotification(model: CanNotifyModel) {
if (isAppOpen) {
// Don't show notifications while the app is open
log("App is open, status notification for site ${model.notifyId()} won't be posted.")
log("App is open, validation error notification for site ${model.notifyId()} won't be posted.")
return
}
log("Posting status notification for site ${model.notifyId()}...")
log("Posting validation error notification for site ${model.notifyId()}...")
val intent = intentProvider.getPendingIntentForViewSite(model)
val newNotification = notificationProvider.create(
channelId = CheckFailures.id,
channelId = ValidationErrors.id,
title = model.notifyName(),
content = stringProvider.get(R.string.something_wrong),
content = model.notifyDescription() ?: stringProvider.get(R.string.something_wrong),
intent = intent,
smallIcon = R.drawable.ic_notification
smallIcon = R.drawable.ic_notification_error
)
stockManager.notify(model.notifyTag(), model.notificationId(), newNotification)
log("Posted status notification for site ${model.notificationId()}.")
log("Posted validation error notification for site ${model.notificationId()}.")
}
override fun postValidationSuccessNotification(model: CanNotifyModel) {
if (isAppOpen) {
// Don't show notifications while the app is open
log("App is open, validation success notification for site ${model.notifyId()} won't be posted.")
return
}
log("Posting validation success notification for site ${model.notifyId()}...")
val intent = intentProvider.getPendingIntentForViewSite(model)
val newNotification = notificationProvider.create(
channelId = ValidationErrors.id,
title = model.notifyName(),
content = stringProvider.get(R.string.validation_passed),
intent = intent,
smallIcon = R.drawable.ic_notification_success
)
stockManager.notify(model.notifyTag(), model.notificationId(), newNotification)
log("Posted validation success notification for site ${model.notificationId()}.")
}
override fun cancelStatusNotification(model: CanNotifyModel) {

View file

@ -0,0 +1,10 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
</vector>

View file

@ -1,12 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="channel_server_check_failures_title">Server Check Failures</string>
<string name="channel_server_check_failures_title">Site Validation Failures</string>
<string name="channel_server_check_failures_description">
Notifications for Nock Nock status checks failing for your sites. Something has gone
Notifications for Nock Nock validations failing for your sites. Something has gone
wrong if you see one of these.
</string>
<string name="channel_server_check_success_title">Site Validation Success</string>
<string name="channel_server_check_success_description">
Notifications for Nock Nock when a site validation passes when it previously had not.
</string>
<string name="something_wrong">Something\'s wrong! Tap for details.</string>
<string name="validation_passed">Yay! No longer in trouble! Validation passed.</string>
</resources>

Some files were not shown because too many files have changed in this diff Show more