diff --git a/.editorconfig b/.editorconfig
deleted file mode 100644
index 17bc1fa..0000000
--- a/.editorconfig
+++ /dev/null
@@ -1,3 +0,0 @@
-[*.kt]
-indent_size = 2
-continuation_indent_size=4
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
deleted file mode 100644
index a574b92..0000000
--- a/.github/ISSUE_TEMPLATE.md
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md
new file mode 100644
index 0000000..2d7d09f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/Bug_report.md
@@ -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.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md
new file mode 100644
index 0000000..77310ae
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/Feature_request.md
@@ -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.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/pull_request_template.md
similarity index 78%
rename from .github/PULL_REQUEST_TEMPLATE.md
rename to .github/pull_request_template.md
index b4035a9..6307e10 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/pull_request_template.md
@@ -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.**
\ No newline at end of file
+**If you do not follow the guidelines, your PR will be rejected.**
diff --git a/.gitignore b/.gitignore
index 161128f..454e51a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -180,4 +180,6 @@ gradle-app.setting
.gradletasknamecache
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
-# gradle/wrapper/gradle-wrapper.properties
\ No newline at end of file
+# gradle/wrapper/gradle-wrapper.properties
+
+app/google-services.json
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 320b3df..50f0406 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -5,7 +5,42 @@
-
+
+
+
+
+
+
+
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 40a7b55..0000000
--- a/.travis.yml
+++ /dev/null
@@ -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:
- - '.+'
diff --git a/README.md b/README.md
index 8e2ecb5..84ee16c 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,8 @@
## Nock Nock
-[](https://travis-ci.org/afollestad/nock-nock)
[](https://www.apache.org/licenses/LICENSE-2.0.html)
-
+
Nock Nock is a simple app which allows you to monitor your websites for maximum uptime.
diff --git a/app/build.gradle b/app/build.gradle
index 7e04aaf..d6b315e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -4,18 +4,6 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
-apply plugin: 'io.fabric'
-
-def getFabricApiKey() {
- def propsFile = project.rootProject.file('local.properties')
- if (!propsFile.exists()) {
- return ""
- }
- Properties properties = new Properties()
- properties.load(propsFile.newDataInputStream())
- return properties.getProperty("fabric.apikey") ?: ""
-}
-
android {
compileSdkVersion versions.compileSdk
buildToolsVersion versions.buildTools
@@ -26,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'
\ No newline at end of file
+apply from: '../spotless.gradle'
+apply from: '../mock/mock.gradle'
+
+apply plugin: "io.fabric"
+apply plugin: 'com.google.gms.google-services'
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b68a968..332578e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -50,9 +50,6 @@
-
diff --git a/app/src/main/java/com/afollestad/nocknock/AppExt.kt b/app/src/main/java/com/afollestad/nocknock/AppExt.kt
index c7af855..828e01f 100644
--- a/app/src/main/java/com/afollestad/nocknock/AppExt.kt
+++ b/app/src/main/java/com/afollestad/nocknock/AppExt.kt
@@ -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 {
diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt
index 2cd0af3..3c29301 100644
--- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt
+++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt
@@ -47,10 +47,8 @@ class NockNockApp : Application() {
Timber.plant(DebugTree())
}
- if (BuildConfig.FABRIC_API_KEY.isNotEmpty()) {
- Timber.plant(FabricTree())
- Fabric.with(this, Crashlytics())
- }
+ Timber.plant(FabricTree())
+ Fabric.with(this, Crashlytics())
val modules = listOf(
prefModule,
diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt
new file mode 100644
index 0000000..7bf22e5
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt
@@ -0,0 +1,115 @@
+/**
+ * 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.adapter
+
+import android.graphics.Color.WHITE
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.OnClickListener
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.adapter.TagAdapter.TagViewHolder
+import kotlinx.android.synthetic.main.list_item_tag.view.chip
+
+typealias TagsListener = (tags: List) -> Unit
+
+/** @author Aidan Follestad (@afollestad) */
+class TagAdapter(
+ private val listener: TagsListener
+) : RecyclerView.Adapter() {
+
+ private val tags = mutableListOf()
+ private val checked = mutableListOf()
+
+ fun set(tags: List) {
+ this.tags.run {
+ clear()
+ addAll(tags)
+ }
+ notifyDataSetChanged()
+ }
+
+ fun toggleChecked(index: Int) {
+ if (checked.contains(index)) {
+ checked.remove(index)
+ } else {
+ checked.add(index)
+ }
+ notifyItemChanged(index)
+ listener.invoke(getCheckedTags())
+ }
+
+ private fun getCheckedTags(): List {
+ return mutableListOf().apply {
+ checked.forEach { index -> add(tags[index]) }
+ }
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): TagViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.list_item_tag, parent, false)
+ return TagViewHolder(view, this)
+ }
+
+ override fun getItemCount() = tags.size
+
+ override fun onBindViewHolder(
+ holder: TagViewHolder,
+ position: Int
+ ) {
+ holder.bind(tags[position], checked.contains(position))
+ }
+
+ /** @author Aidan Follestad (@afollestad) */
+ class TagViewHolder(
+ itemView: View,
+ private val adapter: TagAdapter
+ ) : ViewHolder(itemView), OnClickListener {
+
+ override fun onClick(v: View) = adapter.toggleChecked(adapterPosition)
+
+ init {
+ itemView.setOnClickListener(this)
+ }
+
+ fun bind(
+ name: String,
+ checked: Boolean
+ ) = itemView.chip.run {
+ text = name
+ setTextColor(
+ if (checked) {
+ WHITE
+ } else {
+ ContextCompat.getColor(itemView.context, R.color.unchecked_chip_text)
+ }
+ )
+ setBackgroundResource(
+ if (checked) {
+ R.drawable.checked_chip_selector
+ } else {
+ R.drawable.unchecked_chip_selector
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt
index 7c8e8e4..f0c152f 100644
--- a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt
+++ b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt
@@ -20,6 +20,7 @@ import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import com.afollestad.materialdialogs.MaterialDialog
+import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R
/** @author Aidan Follestad (@afollestad) */
@@ -34,8 +35,9 @@ class AboutDialog : DialogFragment() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- return MaterialDialog(activity!!)
- .title(R.string.about)
+ val context = activity ?: throw IllegalStateException("Oh no!")
+ return MaterialDialog(context)
+ .title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME))
.positiveButton(R.string.dismiss)
.message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
}
diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt
index ba6dc40..07f6410 100644
--- a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt
+++ b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt
@@ -23,6 +23,9 @@ import android.content.Context.NOTIFICATION_SERVICE
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
@@ -38,7 +41,12 @@ val mainModule = module {
single {
databaseBuilder(get(), AppDatabase::class.java, "NockNock.db")
- .addMigrations(Database1to2Migration())
+ .addMigrations(
+ Database1to2Migration(),
+ Database2to3Migration(),
+ Database3to4Migration(),
+ Database4to5Migration()
+ )
.build()
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt
index c43b729..3220567 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt
@@ -15,10 +15,15 @@
*/
package com.afollestad.nocknock.ui
+import android.content.res.Configuration
+import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.afollestad.nocknock.R
import com.afollestad.nocknock.koin.PREF_DARK_MODE
+import com.afollestad.nocknock.ui.NightMode.DISABLED
+import com.afollestad.nocknock.ui.NightMode.ENABLED
+import com.afollestad.nocknock.ui.NightMode.UNKNOWN
import com.afollestad.nocknock.utilities.rx.attachLifecycle
import com.afollestad.rxkprefs.Pref
import org.koin.android.ext.android.inject
@@ -35,16 +40,35 @@ abstract class DarkModeSwitchActivity : AppCompatActivity() {
setTheme(themeRes())
super.onCreate(savedInstanceState)
- darkModePref.observe()
- .filter { it != isDarkModeEnabled }
- .subscribe {
- log("Theme changed, recreating Activity.")
- recreate()
- }
- .attachLifecycle(this)
+ if (getCurrentNightMode() == UNKNOWN) {
+ darkModePref.observe()
+ .filter { it != isDarkModeEnabled }
+ .subscribe {
+ log("Theme changed, recreating Activity.")
+ recreate()
+ }
+ .attachLifecycle(this)
+ }
}
- protected fun isDarkMode() = darkModePref.get()
+ protected fun getCurrentNightMode(): NightMode {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
+ return UNKNOWN
+ }
+ return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
+ Configuration.UI_MODE_NIGHT_YES -> return ENABLED
+ Configuration.UI_MODE_NIGHT_NO -> return DISABLED
+ else -> UNKNOWN
+ }
+ }
+
+ protected fun isDarkMode(): Boolean {
+ return when (getCurrentNightMode()) {
+ ENABLED -> true
+ DISABLED -> false
+ else -> darkModePref.get()
+ }
+ }
protected fun toggleDarkMode() = setDarkMode(!isDarkMode())
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt
new file mode 100644
index 0000000..2930fea
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt
@@ -0,0 +1,26 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui
+
+/** @author Aidan Follestad (@afollestad) */
+enum class NightMode {
+ /** Night mode is on at the system level. */
+ ENABLED,
+ /** Night mode is off at the system level. */
+ DISABLED,
+ /** We don't know about night mode, fallback to custom impl. */
+ UNKNOWN
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt
index e3e9855..e15a29f 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt
@@ -16,18 +16,32 @@
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
import kotlinx.android.synthetic.main.activity_addsite.loadingProgress
import kotlinx.android.synthetic.main.activity_addsite.responseTimeoutInput
@@ -35,44 +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()
+ 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(
@@ -81,8 +105,6 @@ class AddSiteActivity : DarkModeSwitchActivity() {
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
- viewModel.onValidationSearchTermError()
- .toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
@@ -95,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() }
}
@@ -131,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() ?: "")
+ }
}
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt
index ffc3619..d7d8ed5 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt
@@ -25,7 +25,8 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
-import com.afollestad.nocknock.data.RetryPolicy
+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.WAITING
@@ -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,13 +49,14 @@ 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 {
// Public properties
val name = MutableLiveData()
+ val tags = MutableLiveData()
val url = MutableLiveData()
val timeout = MutableLiveData()
val validationMode = MutableLiveData()
@@ -65,6 +66,8 @@ class AddSiteViewModel(
val checkIntervalUnit = MutableLiveData()
val retryPolicyTimes = MutableLiveData()
val retryPolicyMinutes = MutableLiveData()
+ val headers = MutableLiveData>()
+ val certificateUri = MutableLiveData()
@OnLifecycleEvent(ON_START)
fun setDefaults() {
@@ -74,24 +77,14 @@ class AddSiteViewModel(
checkIntervalUnit.value = MINUTE
retryPolicyMinutes.value = 0
retryPolicyMinutes.value = 0
+ tags.value = ""
+ headers.value = emptyList()
}
- // Private properties
private val isLoading = MutableLiveData()
- private val nameError = MutableLiveData()
- private val urlError = MutableLiveData()
- private val timeoutError = MutableLiveData()
- private val validationSearchTermError = MutableLiveData()
- private val validationScriptError = MutableLiveData()
- private val checkIntervalValueError = MutableLiveData()
- // Expose private properties or calculated properties
@CheckResult fun onIsLoading(): LiveData = isLoading
- @CheckResult fun onNameError(): LiveData = nameError
-
- @CheckResult fun onUrlError(): LiveData = urlError
-
@CheckResult fun onUrlWarningVisibility(): LiveData {
return url.map {
val parsed = HttpUrl.parse(it)
@@ -99,8 +92,6 @@ class AddSiteViewModel(
}
}
- @CheckResult fun onTimeoutError(): LiveData = timeoutError
-
@CheckResult fun onValidationModeDescription(): LiveData {
return validationMode.map {
when (it!!) {
@@ -111,17 +102,9 @@ class AddSiteViewModel(
}
}
- @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError
+ @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
- @CheckResult fun onValidationSearchTermVisibility() =
- validationMode.map { it == TERM_SEARCH }
-
- @CheckResult fun onValidationScriptError(): LiveData = validationScriptError
-
- @CheckResult fun onValidationScriptVisibility() =
- validationMode.map { it == JAVASCRIPT }
-
- @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError
+ @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
// Actions
fun commit(done: () -> Unit) {
@@ -132,7 +115,7 @@ class AddSiteViewModel(
val storedModel = withContext(ioDispatcher) {
database.putSite(newModel)
}
- validationManager.scheduleCheck(
+ validationManager.scheduleValidation(
site = storedModel,
rightNow = true,
cancelPrevious = true
@@ -161,75 +144,16 @@ 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
- if (timeout.value.isNullOrLessThan(1)) {
- timeoutError.value = R.string.please_enter_networkTimeout
- errorCount++
- } else {
- timeoutError.value = null
- }
-
- // Validate check interval
- if (checkIntervalValue.value.isNullOrLessThan(1)) {
- checkIntervalValueError.value = R.string.please_enter_check_interval
- errorCount++
- } else {
- checkIntervalValueError.value = null
- }
-
- // Validate arguments
- if (validationMode.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 timeout = timeout.value ?: 10_000
+ val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
val newSettings = SiteSettings(
validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!,
validationArgs = getValidationArgs(),
- networkTimeout = timeout.value!!,
- disabled = false
+ networkTimeout = timeout,
+ disabled = false,
+ certificate = certificateUri.value?.toString()
)
val newLastResult = ValidationResult(
@@ -241,7 +165,10 @@ class AddSiteViewModel(
val retryPolicyTimes = retryPolicyTimes.value ?: 0
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
- RetryPolicy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
+ RetryPolicy(
+ count = retryPolicyTimes,
+ minutes = retryPolicyMinutes
+ )
} else {
null
}
@@ -250,9 +177,11 @@ class AddSiteViewModel(
id = 0,
name = name.value!!.trim(),
url = url.value!!.trim(),
+ tags = cleanedTags,
settings = newSettings,
lastResult = newLastResult,
- retryPolicy = newRetryPolicy
+ retryPolicy = newRetryPolicy,
+ headers = headers.value ?: emptyList()
)
}
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt
new file mode 100644
index 0000000..c524555
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt
@@ -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()
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt
index eba93e4..aec76d8 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt
@@ -21,20 +21,19 @@ import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
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
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
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
@@ -43,6 +42,7 @@ import kotlinx.android.synthetic.main.include_app_bar.toolbar
import kotlinx.android.synthetic.main.include_empty_view.emptyText
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
+import kotlinx.android.synthetic.main.activity_main.tags_list as tagsList
/** @author Aidan Follestad (@afollestad) */
class MainActivity : DarkModeSwitchActivity() {
@@ -53,6 +53,7 @@ class MainActivity : DarkModeSwitchActivity() {
internal val viewModel by viewModel()
private lateinit var siteAdapter: SiteAdapter
+ private lateinit var tagAdapter: TagAdapter
private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) {
@@ -76,6 +77,10 @@ class MainActivity : DarkModeSwitchActivity() {
.observe(this, Observer { siteAdapter.set(it) })
viewModel.onEmptyTextVisibility()
.toViewVisibility(this, emptyText)
+ viewModel.onTags()
+ .observe(this, Observer { tagAdapter.set(it) })
+ viewModel.onTagsListVisibility()
+ .toViewVisibility(this, tagsList)
loadingProgress.observe(this, viewModel.onIsLoading())
processIntent(intent)
@@ -85,24 +90,35 @@ 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
}
}
siteAdapter = SiteAdapter(this::onSiteSelected)
-
list.run {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = siteAdapter
addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL))
}
+
+ tagAdapter = TagAdapter(viewModel::onTagSelection)
+ tagsList.run {
+ layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false)
+ adapter = tagAdapter
+ }
+
fab.setOnClickListener { addSite() }
}
@@ -121,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)
}
}
}
@@ -129,20 +146,4 @@ class MainActivity : DarkModeSwitchActivity() {
viewSite(model)
}
}
-
- private fun supportMe() {
- MaterialDialog(this).show {
- title(R.string.support_me)
- message(R.string.support_me_message, html = true, lineHeightMultiplier = 1.4f)
- listItemsSingleChoice(R.array.donation_options) { _, index, _ ->
- when (index) {
- 0 -> viewUrl("https://paypal.me/AidanFollestad")
- 1 -> viewUrlWithApp("https://cash.me/\$afollestad", pkg = "com.squareup.cash")
- 2 -> viewUrlWithApp("https://venmo.com/afollestad", pkg = "com.venmo")
- }
- toast(R.string.thank_you)
- }
- positiveButton(R.string.next)
- }
- }
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt
index 422cdc5..e11ca08 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt
@@ -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)
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt
index bfb9bb9..bad64fc 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt
@@ -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 {
@@ -44,6 +44,8 @@ class MainViewModel(
private val sites = MutableLiveData>()
private val isLoading = MutableLiveData()
private val emptyTextVisibility = MutableLiveData()
+ private val tags = MutableLiveData>()
+ private val tagsListVisibility = MutableLiveData()
@CheckResult fun onSites(): LiveData> = sites
@@ -51,8 +53,14 @@ class MainViewModel(
@CheckResult fun onEmptyTextVisibility(): LiveData = emptyTextVisibility
+ @CheckResult fun onTags(): LiveData> = tags
+
+ @CheckResult fun onTagsListVisibility(): LiveData = tagsListVisibility
+
@OnLifecycleEvent(ON_RESUME)
- fun onResume() = loadSites()
+ fun onResume() = loadSites(emptyList())
+
+ fun onTagSelection(tags: List) = loadSites(tags)
fun postSiteUpdate(model: Site) {
val currentSites = sites.value ?: return
@@ -65,7 +73,7 @@ class MainViewModel(
}
fun refreshSite(model: Site) {
- validationManager.scheduleCheck(
+ validationManager.scheduleValidation(
site = model,
rightNow = true,
cancelPrevious = true
@@ -73,7 +81,7 @@ class MainViewModel(
}
fun removeSite(model: Site) {
- validationManager.cancelCheck(model)
+ validationManager.cancelScheduledValidation(model)
notificationManager.cancelStatusNotification(model)
scope.launch {
@@ -94,27 +102,56 @@ class MainViewModel(
}
}
- private fun loadSites() {
+ private fun loadSites(forTags: List) {
scope.launch {
notificationManager.cancelStatusNotifications()
- sites.value = listOf()
emptyTextVisibility.value = false
isLoading.value = true
- val result = withContext(ioDispatcher) {
+ val unfiltered = withContext(ioDispatcher) {
database.allSites()
}
+ var result = unfiltered
+
+ if (forTags.isNotEmpty()) {
+ result = result.filter { site ->
+ val itemTags = site.tags.toLowerCase()
+ .split(",")
+ itemTags.any { tag -> forTags.contains(tag) }
+ }
+ }
sites.value = result
ensureCheckJobs()
isLoading.value = false
emptyTextVisibility.value = result.isEmpty()
+
+ val tagsValues = pullOutTags(unfiltered)
+ tags.value = tagsValues
+ tagsListVisibility.value = tagsValues.isNotEmpty()
}
}
private suspend fun ensureCheckJobs() {
withContext(ioDispatcher) {
- validationManager.ensureScheduledChecks()
+ validationManager.ensureScheduledValidations()
+ }
+ }
+
+ private fun pullOutTags(sites: List): List {
+ return mutableListOf().apply {
+ for (site in sites) {
+ val splitTags = site.tags.toLowerCase()
+ .split(',')
+ splitTags
+ .filter { it.isNotEmpty() }
+ .forEach { tag ->
+ if (!this.contains(tag)) {
+ this.add(tag)
+ }
+ }
+ }
+ sort()
}
}
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt
index a193c6f..2aa312c 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt
@@ -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,18 +27,23 @@ 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
import kotlinx.android.synthetic.main.activity_viewsite.inputUrl
import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress
import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput
@@ -45,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
@@ -52,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()
+ private lateinit var validationForm: Form
private val intentProvider by inject()
private val statusUpdateReceiver by lazy {
@@ -70,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())
@@ -92,20 +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(
@@ -114,8 +126,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
- viewModel.onValidationSearchTermError()
- .toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
@@ -124,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()
@@ -152,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
}
@@ -186,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() ?: "")
}
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt
index 536eadd..b5c9f93 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt
@@ -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.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 {
@@ -64,6 +64,7 @@ class ViewSiteViewModel(
// Public properties
val status = MutableLiveData()
val name = MutableLiveData()
+ val tags = MutableLiveData()
val url = MutableLiveData()
val timeout = MutableLiveData()
val validationMode = MutableLiveData()
@@ -73,25 +74,15 @@ class ViewSiteViewModel(
val checkIntervalUnit = MutableLiveData()
val retryPolicyTimes = MutableLiveData()
val retryPolicyMinutes = MutableLiveData()
+ val headers = MutableLiveData>()
+ val certificateUri = MutableLiveData()
internal val disabled = MutableLiveData()
internal val lastResult = MutableLiveData()
- // Private properties
private val isLoading = MutableLiveData()
- private val nameError = MutableLiveData()
- private val urlError = MutableLiveData()
- private val timeoutError = MutableLiveData()
- private val validationSearchTermError = MutableLiveData()
- private val validationScriptError = MutableLiveData()
- private val checkIntervalValueError = MutableLiveData()
- // Expose private properties or calculated properties
@CheckResult fun onIsLoading(): LiveData = isLoading
- @CheckResult fun onNameError(): LiveData = nameError
-
- @CheckResult fun onUrlError(): LiveData = urlError
-
@CheckResult fun onUrlWarningVisibility(): LiveData {
return url.map {
val parsed = HttpUrl.parse(it)
@@ -99,8 +90,6 @@ class ViewSiteViewModel(
}
}
- @CheckResult fun onTimeoutError(): LiveData = timeoutError
-
@CheckResult fun onValidationModeDescription(): LiveData {
return validationMode.map {
when (it!!) {
@@ -111,20 +100,11 @@ class ViewSiteViewModel(
}
}
- @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError
+ @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
- @CheckResult fun onValidationSearchTermVisibility() =
- validationMode.map { it == TERM_SEARCH }
+ @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
- @CheckResult fun onValidationScriptError(): LiveData = validationScriptError
-
- @CheckResult fun onValidationScriptVisibility() =
- validationMode.map { it == JAVASCRIPT }
-
- @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError
-
- @CheckResult fun onDisableChecksVisibility(): LiveData =
- disabled.map { !it }
+ @CheckResult fun onDisableChecksVisibility(): LiveData = disabled.map { !it }
@CheckResult fun onDoneButtonText(): LiveData =
disabled.map {
@@ -168,7 +148,7 @@ class ViewSiteViewModel(
withContext(ioDispatcher) {
database.updateSite(updatedModel)
}
- validationManager.scheduleCheck(
+ validationManager.scheduleValidation(
site = updatedModel,
rightNow = true,
cancelPrevious = true
@@ -184,7 +164,7 @@ class ViewSiteViewModel(
status = WAITING
)
setModel(checkModel)
- validationManager.scheduleCheck(
+ validationManager.scheduleValidation(
site = checkModel,
rightNow = true,
cancelPrevious = true
@@ -192,7 +172,7 @@ class ViewSiteViewModel(
}
fun removeSite(done: () -> Unit) {
- validationManager.cancelCheck(site)
+ validationManager.cancelScheduledValidation(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
@@ -206,7 +186,7 @@ class ViewSiteViewModel(
}
fun disableSite() {
- validationManager.cancelCheck(site)
+ validationManager.cancelScheduledValidation(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
@@ -242,75 +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
- if (timeout.value.isNullOrLessThan(1)) {
- timeoutError.value = R.string.please_enter_networkTimeout
- errorCount++
- } else {
- timeoutError.value = null
- }
-
- // Validate check interval
- if (checkIntervalValue.value.isNullOrLessThan(1)) {
- checkIntervalValueError.value = R.string.please_enter_check_interval
- errorCount++
- } else {
- checkIntervalValueError.value = null
- }
-
- // Validate arguments
- if (validationMode.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 timeout = timeout.value ?: 10_000
+ val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: ""
val newSettings = site.settings!!.copy(
validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!,
validationArgs = getValidationArgs(),
- networkTimeout = timeout.value!!,
- disabled = false
+ networkTimeout = timeout,
+ disabled = false,
+ certificate = certificateUri.value?.toString()
)
val retryPolicyTimes = retryPolicyTimes.value ?: 0
@@ -318,10 +239,16 @@ 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)
+ RetryPolicy(
+ count = retryPolicyTimes,
+ minutes = retryPolicyMinutes
+ )
}
} else {
// No policy
@@ -330,9 +257,11 @@ class ViewSiteViewModel(
return site.copy(
name = name.value!!.trim(),
+ tags = cleanedTags,
url = url.value!!.trim(),
settings = newSettings,
- retryPolicy = retryPolicy
+ retryPolicy = retryPolicy,
+ headers = headers.value ?: emptyList()
)
.withStatus(status = WAITING)
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt
index d13341a..800f235 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt
@@ -15,7 +15,7 @@
*/
package com.afollestad.nocknock.ui.viewsite
-import com.afollestad.nocknock.data.RetryPolicy
+import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
@@ -32,6 +32,7 @@ fun ViewSiteViewModel.setModel(site: Site) {
status.value = site.lastResult?.status ?: WAITING
name.value = site.name
+ tags.value = site.tags
url.value = site.url
timeout.value = settings.networkTimeout
@@ -53,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
@@ -62,22 +69,22 @@ private fun ViewSiteViewModel.setCheckInterval(interval: Long) {
when {
interval >= WEEK -> {
checkIntervalValue.value =
- getIntervalFromUnit(interval, WEEK)
+ getIntervalFromUnit(interval, WEEK)
checkIntervalUnit.value = WEEK
}
interval >= DAY -> {
checkIntervalValue.value =
- getIntervalFromUnit(interval, DAY)
+ getIntervalFromUnit(interval, DAY)
checkIntervalUnit.value = DAY
}
interval >= HOUR -> {
checkIntervalValue.value =
- getIntervalFromUnit(interval, HOUR)
+ getIntervalFromUnit(interval, HOUR)
checkIntervalUnit.value = HOUR
}
interval >= MINUTE -> {
checkIntervalValue.value =
- getIntervalFromUnit(interval, MINUTE)
+ getIntervalFromUnit(interval, MINUTE)
checkIntervalUnit.value = MINUTE
}
else -> {
diff --git a/app/src/main/res/color/unchecked_chip_text.xml b/app/src/main/res/color/unchecked_chip_text.xml
new file mode 100644
index 0000000..8e7f4df
--- /dev/null
+++ b/app/src/main/res/color/unchecked_chip_text.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/checked_chip.xml b/app/src/main/res/drawable/checked_chip.xml
new file mode 100644
index 0000000..85010f5
--- /dev/null
+++ b/app/src/main/res/drawable/checked_chip.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/checked_chip_pressed.xml b/app/src/main/res/drawable/checked_chip_pressed.xml
new file mode 100644
index 0000000..0d7c176
--- /dev/null
+++ b/app/src/main/res/drawable/checked_chip_pressed.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/checked_chip_selector.xml b/app/src/main/res/drawable/checked_chip_selector.xml
new file mode 100644
index 0000000..fa9df00
--- /dev/null
+++ b/app/src/main/res/drawable/checked_chip_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml
new file mode 100644
index 0000000..00fc15d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_check.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/unchecked_chip.xml b/app/src/main/res/drawable/unchecked_chip.xml
new file mode 100644
index 0000000..1864bc5
--- /dev/null
+++ b/app/src/main/res/drawable/unchecked_chip.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/unchecked_chip_pressed.xml b/app/src/main/res/drawable/unchecked_chip_pressed.xml
new file mode 100644
index 0000000..c387d70
--- /dev/null
+++ b/app/src/main/res/drawable/unchecked_chip_pressed.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/unchecked_chip_selector.xml b/app/src/main/res/drawable/unchecked_chip_selector.xml
new file mode 100644
index 0000000..ba01f74
--- /dev/null
+++ b/app/src/main/res/drawable/unchecked_chip_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml
index 77765d1..80f83da 100644
--- a/app/src/main/res/layout/activity_addsite.xml
+++ b/app/src/main/res/layout/activity_addsite.xml
@@ -16,6 +16,7 @@
@@ -24,59 +25,61 @@
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"
>
-
+
-
+
-
+
-
-
-
-
-
+
+
+
+
+
@@ -88,35 +91,10 @@
android:layout_marginTop="@dimen/content_inset"
/>
-
-
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index fb13d54..1e95b7f 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -17,6 +17,19 @@
+
+
+
+
@@ -55,24 +70,13 @@
android:orientation="vertical"
>
-
-
+
+
@@ -109,35 +126,6 @@
android:layout_marginTop="@dimen/content_inset"
/>
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
diff --git a/app/src/main/res/layout/list_item_tag.xml b/app/src/main/res/layout/list_item_tag.xml
new file mode 100644
index 0000000..0ae5932
--- /dev/null
+++ b/app/src/main/res/layout/list_item_tag.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/app/src/main/res/menu/menu_addsite.xml b/app/src/main/res/menu/menu_addsite.xml
new file mode 100644
index 0000000..e346eb9
--- /dev/null
+++ b/app/src/main/res/menu/menu_addsite.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
index b3b1727..5f2f23f 100644
--- a/app/src/main/res/menu/menu_main.xml
+++ b/app/src/main/res/menu/menu_main.xml
@@ -7,7 +7,4 @@
android:id="@+id/dark_mode"
android:checkable="true"
android:title="@string/dark_mode"/>
-
diff --git a/app/src/main/res/menu/menu_viewsite.xml b/app/src/main/res/menu/menu_viewsite.xml
index f46e13d..fee6f5a 100644
--- a/app/src/main/res/menu/menu_viewsite.xml
+++ b/app/src/main/res/menu/menu_viewsite.xml
@@ -1,17 +1,23 @@
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..80b730f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..80b730f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
index d1698c3..a86dbab 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..eb43a7b
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..4567198
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
index cccfa5b..60056be 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..666c904
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..3baff41
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index a4f8356..27f30d2 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..4224797
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1d29a54
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 49dd226..60a8d1a 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..7cf19eb
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1548eb9
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index bd57c86..ac61bd3 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..9c43fc9
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..04806fd
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index d4e6453..84ad226 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -3,6 +3,7 @@
- @string/refresh_status
+ - @string/duplicate_and_modify
- @string/remove_site
@@ -12,10 +13,4 @@
- JavaScript Evaluation
-
- - via PayPal
- - via Cash App
- - via Venmo
-
-
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index 0df2f69..35d3041 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -4,5 +4,6 @@
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f6465db..f840cee 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,7 +7,11 @@
#212121
#252525
- #303030
+ #303030
+ #EEEEEE
+
#FF6E40
+ #E44615
+ #40FF6E40
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 4c19a47..510a2e9 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -2,5 +2,6 @@
28sp
6dp
+ 4dp
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..70daa76
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #758F9A
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e7506bf..3ac40a0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,35 +1,42 @@
Nock Nock
+ Nock Nock %1$s
No sites added!
About
Nock Nock, a simple app designed by Aidan Follestad.
+ A simple app designed by Aidan Follestad.
Website
Twitter
GitHub
LinkedIn
Nock Nock is open source! Check out the GitHub page!
Icon by Kevin Aguilar of 221 Pixels.
+
View the Privacy Policy.
]]>
Dark Mode
Dismiss
Add Site
Site Name
+ Site display name
Site URL
+ https://yoursite.com
+ Site Tags
+ e.g. One,Two,Three
+ Tags (e.g. One,Two,Three)
Please enter a name!
Please enter a URL.
Please enter a valid URL.
- Please input a validation interval.
Please input a search term.
- Please input a validation script.
Please enter a network timeout greater than 0.
+ Certificate should be a valid file or content URI.
Options
Remove Site
+ Duplicate and Modify
%1$s from your sites?]]>
Remove
Save Changes
@@ -43,8 +50,8 @@
Disable Automatic Validation
%1$s? 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.
]]>
Disable
Enable Auto Validation & Save Changes
@@ -52,6 +59,10 @@
Network Response Timeout (ms)
10000
+ SSL Certificate
+ (Automatic)
+ Browse
+
Refresh Status
@@ -74,14 +85,6 @@
exception to pass custom error messages to Nock Nock.
- Donate
- Nock Nock was created and is maintained by one person. Donations are much
- appreciated and encourage continued support.
- ]]>
- Thank you very much!
- Next
-
Please install a web browser app, such as Google Chrome.
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 22a2da4..e4b435a 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -4,15 +4,9 @@
-
-
diff --git a/app/src/main/res/values/styles_parents.xml b/app/src/main/res/values/styles_parents.xml
index decbdb8..dc5e3d6 100644
--- a/app/src/main/res/values/styles_parents.xml
+++ b/app/src/main/res/values/styles_parents.xml
@@ -9,6 +9,7 @@
- #000000
- #EEEEEE
- #000000
+ - @color/lighterGray
- #212121
- #727272
@@ -33,6 +34,7 @@
- #ffffff
- #303030
- #FFFFFF
+ - @color/darkerGray
- #FFFFFF
- #F0F0F0
diff --git a/app/src/main/res/values/styles_text.xml b/app/src/main/res/values/styles_text.xml
index e0fc849..df25036 100644
--- a/app/src/main/res/values/styles_text.xml
+++ b/app/src/main/res/values/styles_text.xml
@@ -6,4 +6,28 @@
- ?toolbarTitleColor
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/afollestad/nocknock/TestData.kt b/app/src/test/java/com/afollestad/nocknock/TestData.kt
index 770688b..e5e4c29 100644
--- a/app/src/test/java/com/afollestad/nocknock/TestData.kt
+++ b/app/src/test/java/com/afollestad/nocknock/TestData.kt
@@ -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.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,18 +82,31 @@ fun fakeRetryPolicy(
minutes = minutes
)
-fun fakeModel(id: Long) = Site(
+fun fakeHeaders(siteId: Long): List {
+ 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,
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 {
@@ -155,12 +171,29 @@ fun mockDatabase(): AppDatabase {
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
+ val headerDao = mock {
+ on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(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()) } doReturn 1L
+ on { insert(isA>()) } 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
}
}
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt
index 9acd165..1319310 100644
--- a/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt
+++ b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt
@@ -17,20 +17,21 @@ package com.afollestad.nocknock.ui.addsite
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
+import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
-import com.afollestad.nocknock.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()
+ private val validationManager = mock()
@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()
val settingsCaptor = argumentCaptor()
+ val validationResultCaptor = argumentCaptor()
isLoading.assertValues(true, false)
verify(database.siteDao()).insert(siteCaptor.capture())
verify(database.siteSettingsDao()).insert(settingsCaptor.capture())
- verify(database.validationResultsDao(), never()).insert(any())
+ verify(database.validationResultsDao()).insert(validationResultCaptor.capture())
val settings = settingsCaptor.firstValue
+ val result = validationResultCaptor.firstValue.copy(siteId = 1)
val model = siteCaptor.firstValue.copy(
id = 1, // fill it in because our insert captor doesn't catch this
settings = settings,
- lastResult = null
+ lastResult = result
)
- 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")
+ )
}
}
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt
index 776c39e..593996e 100644
--- a/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt
+++ b/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt
@@ -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()
- private val validationManager = mock()
+ private val validationManager = mock()
@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!!)
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt
index 04e1c79..47fe6e0 100644
--- a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt
+++ b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt
@@ -18,6 +18,8 @@ package com.afollestad.nocknock.ui.viewsite
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.afollestad.nocknock.MOCK_MODEL_1
import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.model.Header
+import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status.CHECKING
@@ -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()
+ private val validationManager = mock()
private val notificationManager = mock()
@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()
val settingsCaptor = argumentCaptor()
val resultCaptor = argumentCaptor()
+ val retryPolicyCaptor = argumentCaptor()
isLoading.assertValues(true, false)
verify(database.siteDao()).update(siteCaptor.capture())
verify(database.siteSettingsDao()).update(settingsCaptor.capture())
verify(database.validationResultsDao()).update(resultCaptor.capture())
+ verify(database.retryPolicyDao()).update(retryPolicyCaptor.capture())
// From fillInModel() below
val updatedSettings = MOCK_MODEL_1.settings!!.copy(
@@ -523,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")
+ )
}
}
diff --git a/art/showcase5.png b/art/showcase5.png
new file mode 100644
index 0000000..6198f1f
Binary files /dev/null and b/art/showcase5.png differ
diff --git a/art/showcasemain3.png b/art/showcasemain3.png
deleted file mode 100644
index 93957b9..0000000
Binary files a/art/showcasemain3.png and /dev/null differ
diff --git a/build.gradle b/build.gradle
index d99c0ee..2d8e462 100644
--- a/build.gradle
+++ b/build.gradle
@@ -15,6 +15,7 @@ buildscript {
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin
classpath 'com.github.ben-manes:gradle-versions-plugin:' + versions.versionPlugin
classpath 'io.fabric.tools:gradle:' + versions.fabricPlugin
+ classpath 'com.google.gms:google-services:' + versions.googleServices
}
}
@@ -22,7 +23,6 @@ allprojects {
repositories {
google()
jcenter()
- maven { url "https://dl.bintray.com/drummer-aidan/maven" }
maven { url "https://jitpack.io" }
}
diff --git a/common/build.gradle b/common/build.gradle
index 8472928..5004349 100644
--- a/common/build.gradle
+++ b/common/build.gradle
@@ -12,6 +12,10 @@ android {
versionName versions.publishVersion
}
+ packagingOptions {
+ exclude 'META-INF/atomicfu.kotlin_module'
+ }
+
// For Mozilla Rhino
lintOptions {
abortOnError false
@@ -30,6 +34,7 @@ dependencies {
implementation 'org.mozilla:rhino:' + versions.rhino
api 'com.afollestad:rxkprefs:' + versions.rxkPrefs
+ api "io.reactivex.rxjava2:rxjava:" + versions.rxJava
testImplementation 'junit:junit:' + versions.junit
testImplementation 'com.google.truth:truth:' + versions.truth
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt
new file mode 100644
index 0000000..1256a27
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt
@@ -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()
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
index 86482e3..389de77 100644
--- a/common/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
@@ -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)
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt
index a177c49..773243f 100644
--- a/common/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt
@@ -30,6 +30,8 @@ interface CanNotifyModel : Serializable {
fun notifyName(): String
fun notifyTag(): String
+
+ fun notifyDescription(): String?
}
/** @author Aidan Follestad (@afollestad) */
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationProvider.kt
index 6a85298..391fcb4 100644
--- a/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationProvider.kt
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationProvider.kt
@@ -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()
}
}
diff --git a/common/src/test/kotlin/com/afollestad/nocknock/utilities/livedata/DistinctTest.kt b/common/src/test/java/com/afollestad/nocknock/utilities/livedata/DistinctTest.kt
similarity index 100%
rename from common/src/test/kotlin/com/afollestad/nocknock/utilities/livedata/DistinctTest.kt
rename to common/src/test/java/com/afollestad/nocknock/utilities/livedata/DistinctTest.kt
diff --git a/common/src/test/kotlin/com/afollestad/nocknock/utilities/livedata/ZipTest.kt b/common/src/test/java/com/afollestad/nocknock/utilities/livedata/ZipTest.kt
similarity index 100%
rename from common/src/test/kotlin/com/afollestad/nocknock/utilities/livedata/ZipTest.kt
rename to common/src/test/java/com/afollestad/nocknock/utilities/livedata/ZipTest.kt
diff --git a/data/build.gradle b/data/build.gradle
index 5512756..e413d4c 100644
--- a/data/build.gradle
+++ b/data/build.gradle
@@ -14,6 +14,10 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
+
+ packagingOptions {
+ exclude 'META-INF/atomicfu.kotlin_module'
+ }
}
dependencies {
diff --git a/data/src/androidTest/AndroidManifest.xml b/data/src/androidTest/AndroidManifest.xml
index 17a2157..c83c51d 100644
--- a/data/src/androidTest/AndroidManifest.xml
+++ b/data/src/androidTest/AndroidManifest.xml
@@ -4,7 +4,7 @@
diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt
index 140d9c6..e7d4400 100644
--- a/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt
+++ b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt
@@ -21,6 +21,8 @@ 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
import com.afollestad.nocknock.data.model.Status.ERROR
@@ -46,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()
@@ -54,13 +57,12 @@ class AppDatabaseTest() {
settingsDao = db.siteSettingsDao()
resultsDao = db.validationResultsDao()
retryDao = db.retryPolicyDao()
+ headerDao = db.headerDao()
}
@After
@Throws(IOException::class)
- fun destroy() {
- db.close()
- }
+ fun destroy() = db.close()
// SiteDao
@@ -68,9 +70,11 @@ class AppDatabaseTest() {
val model1 = Site(
name = "Test 1",
url = "https://test1.com",
+ tags = "",
settings = null,
lastResult = null,
- retryPolicy = null
+ retryPolicy = null,
+ headers = emptyList()
)
val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0)
@@ -78,9 +82,11 @@ class AppDatabaseTest() {
val model2 = Site(
name = "Test 2",
url = "https://test2.com",
+ tags = "",
settings = null,
lastResult = null,
- retryPolicy = null
+ retryPolicy = null,
+ headers = emptyList()
)
val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1)
@@ -95,9 +101,11 @@ class AppDatabaseTest() {
val model = Site(
name = "Test",
url = "https://test.com",
+ tags = "",
settings = null,
lastResult = null,
- retryPolicy = null
+ retryPolicy = null,
+ headers = emptyList()
)
val newId = sitesDao.insert(model)
assertThat(newId).isGreaterThan(0)
@@ -110,9 +118,11 @@ class AppDatabaseTest() {
val initialModel = Site(
name = "Test 1",
url = "https://test1.com",
+ tags = "",
settings = null,
lastResult = null,
- retryPolicy = null
+ retryPolicy = null,
+ headers = emptyList()
)
val newId = sitesDao.insert(initialModel)
assertThat(newId).isGreaterThan(0)
@@ -134,9 +144,11 @@ class AppDatabaseTest() {
val model1 = Site(
name = "Test 1",
url = "https://test1.com",
+ tags = "",
settings = null,
lastResult = null,
- retryPolicy = null
+ retryPolicy = null,
+ headers = emptyList()
)
val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0)
@@ -144,9 +156,11 @@ class AppDatabaseTest() {
val model2 = Site(
name = "Test 2",
url = "https://test2.com",
+ tags = "",
settings = null,
lastResult = null,
- retryPolicy = null
+ retryPolicy = null,
+ headers = emptyList()
)
val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1)
@@ -167,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)
@@ -185,7 +200,8 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE,
validationArgs = null,
disabled = false,
- networkTimeout = 10000
+ networkTimeout = 10000,
+ certificate = null
)
)
@@ -213,7 +229,8 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE,
validationArgs = null,
disabled = false,
- networkTimeout = 10000
+ networkTimeout = 10000,
+ certificate = null
)
)
@@ -291,7 +308,7 @@ class AppDatabaseTest() {
val newId = retryDao.insert(model)
assertThat(newId).isEqualTo(1)
- val finalModel = resultsDao.forSite(newId)
+ val finalModel = retryDao.forSite(newId)
.single()
assertThat(finalModel).isEqualTo(model.copy(siteId = newId))
}
@@ -333,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() {
@@ -342,9 +431,30 @@ class AppDatabaseTest() {
val allSites = db.allSites()
assertThat(allSites.size).isEqualTo(3)
- assertThat(allSites[0]).isEqualTo(MOCK_MODEL_1)
- assertThat(allSites[1]).isEqualTo(MOCK_MODEL_2)
- assertThat(allSites[2]).isEqualTo(MOCK_MODEL_3)
+ assertThat(allSites[0]).isEqualTo(
+ MOCK_MODEL_1.copy(
+ headers = listOf(
+ MOCK_MODEL_1.headers.first().copy(id = 1),
+ MOCK_MODEL_1.headers.last().copy(id = 2)
+ )
+ )
+ )
+ assertThat(allSites[1]).isEqualTo(
+ MOCK_MODEL_2.copy(
+ headers = listOf(
+ MOCK_MODEL_2.headers.first().copy(id = 3),
+ MOCK_MODEL_2.headers.last().copy(id = 4)
+ )
+ )
+ )
+ assertThat(allSites[2]).isEqualTo(
+ MOCK_MODEL_3.copy(
+ headers = listOf(
+ MOCK_MODEL_3.headers.first().copy(id = 5),
+ MOCK_MODEL_3.headers.last().copy(id = 6)
+ )
+ )
+ )
}
@Test fun extension_put_getSite() {
@@ -379,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)
@@ -393,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)
}
@@ -402,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)
@@ -418,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())
}
}
diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt
index 6782694..2866b50 100644
--- a/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt
+++ b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt
@@ -15,6 +15,8 @@
*/
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
import com.afollestad.nocknock.data.model.Status
@@ -33,7 +35,8 @@ fun fakeSettingsModel(
validationMode = validationMode,
validationArgs = null,
disabled = false,
- networkTimeout = 10000
+ networkTimeout = 10000,
+ certificate = null
)
fun fakeResultModel(
@@ -57,13 +60,20 @@ 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",
url = "https://test.com",
+ tags = "",
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id),
- retryPolicy = fakeRetryPolicy(id)
+ retryPolicy = fakeRetryPolicy(id),
+ headers = fakeHeaders(id)
)
val MOCK_MODEL_1 = fakeModel(1)
diff --git a/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt
index c80b9f8..47687ba 100644
--- a/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt
+++ b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt
@@ -19,6 +19,8 @@ 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
import com.afollestad.nocknock.data.model.ValidationResult
@@ -26,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 = 2,
+ version = 5,
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -44,6 +47,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun validationResultsDao(): ValidationResultsDao
abstract fun retryPolicyDao(): RetryPolicyDao
+
+ abstract fun headerDao(): HeaderDao
}
/**
@@ -60,10 +65,12 @@ fun AppDatabase.allSites(): List {
.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
)
}
}
@@ -82,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
)
}
@@ -100,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
)
}
@@ -151,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))
+ }
}
/**
@@ -162,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)
}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt b/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt
index 53a9275..0b158b8 100644
--- a/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt
+++ b/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt
@@ -27,7 +27,45 @@ 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)"
+ "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)"
)
}
}
+
+/**
+ * Migrates the database from version 2 to 3.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+class Database2to3Migration : Migration(2, 3) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ 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")
+ }
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt b/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt
new file mode 100644
index 0000000..c34f0cb
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt
@@ -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
+
+ @Query("SELECT * FROM headers WHERE siteId = :siteId")
+ fun forSite(siteId: Long): List
+
+ @Insert(onConflict = FAIL)
+ fun insert(headers: Header): Long
+
+ @Insert(onConflict = FAIL)
+ fun insert(headers: List): List
+
+ @Update(onConflict = FAIL)
+ fun update(header: Header): Int
+
+ @Delete
+ fun delete(headers: List): Int
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/RetryPolicyDao.kt b/data/src/main/java/com/afollestad/nocknock/data/RetryPolicyDao.kt
index 50f1fa2..e130fed 100644
--- a/data/src/main/java/com/afollestad/nocknock/data/RetryPolicyDao.kt
+++ b/data/src/main/java/com/afollestad/nocknock/data/RetryPolicyDao.kt
@@ -21,6 +21,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy.FAIL
import androidx.room.Query
import androidx.room.Update
+import com.afollestad.nocknock.data.model.RetryPolicy
/** @author Aidan Follestad (@afollestad) */
@Dao
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt
new file mode 100644
index 0000000..9460af0
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt
@@ -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, "", "")
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/RetryPolicy.kt b/data/src/main/java/com/afollestad/nocknock/data/model/RetryPolicy.kt
similarity index 89%
rename from data/src/main/java/com/afollestad/nocknock/data/RetryPolicy.kt
rename to data/src/main/java/com/afollestad/nocknock/data/model/RetryPolicy.kt
index d3443cf..a5c7831 100644
--- a/data/src/main/java/com/afollestad/nocknock/data/RetryPolicy.kt
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/RetryPolicy.kt
@@ -15,7 +15,7 @@
*/
@file:Suppress("unused")
-package com.afollestad.nocknock.data
+package com.afollestad.nocknock.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -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
}
}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt
index a22314c..98feedc 100644
--- a/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt
@@ -18,7 +18,6 @@ package com.afollestad.nocknock.data.model
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
-import com.afollestad.nocknock.data.RetryPolicy
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.utilities.ext.timeString
import com.afollestad.nocknock.utilities.providers.CanNotifyModel
@@ -32,17 +31,21 @@ data class Site(
@PrimaryKey(autoGenerate = true) var id: Long = 0,
/** The site's user-given name. */
var name: String,
- /** The URl at which validation attempts are made to. */
+ /** The URL at which validation attempts are made to. */
var url: String,
+ /** Comma separated tags for this site. */
+ var tags: String,
/** Settings for the site. */
@Ignore var settings: SiteSettings?,
/** 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
) : CanNotifyModel {
- constructor() : this(0, "", "", null, null, null)
+ constructor() : this(0, "", "", "", null, null, null, emptyList())
override fun notifyId(): Int = id.toInt()
@@ -50,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
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt b/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt
index 91667a7..bc73589 100644
--- a/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt
@@ -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)
}
diff --git a/dependencies.gradle b/dependencies.gradle
index 9432a8e..e0e2412 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -3,52 +3,56 @@ ext.versions = [
minSdk : 21,
compileSdk : 28,
buildTools : '28.0.3',
- publishVersion : '0.8.2b',
- publishVersionCode : 33,
+ publishVersion : '0.8.8',
+ publishVersionCode : 46,
// Plugins
- gradlePlugin : '3.2.1',
- spotlessPlugin : '3.17.0',
- versionPlugin : '0.20.0',
+ gradlePlugin : '3.4.0',
+ spotlessPlugin : '3.22.0',
+ versionPlugin : '0.21.0',
+ googleServices : '4.2.0',
fabricPlugin : '1.+',
// Misc
- okHttp : '3.12.1',
+ okHttp : '3.14.1',
rhino : '1.7.10',
// Kotlin
- kotlin : '1.3.11',
- coroutines : '1.1.0',
+ kotlin : '1.3.30',
+ coroutines : '1.2.0',
koin : '1.0.2',
// Google/AndroidX
- androidxAnnotations : '1.0.1',
+ androidxAnnotations : '1.0.2',
androidxCore : '1.0.2',
androidxRecyclerView: '1.0.0',
androidxBrowser : '1.0.0',
googleMaterial : '1.0.0',
room : '2.0.0',
lifecycle : '2.0.0',
+ firebaseCore : '16.0.8',
// Rx
+ rxJava : '2.2.8',
rxBinding : '3.0.0-alpha1',
// afollestad
- materialDialogs : '2.0.0-rc7',
- rxkPrefs : '1.2.1',
+ materialDialogs : '2.8.1',
+ rxkPrefs : '1.2.5',
+ vvalidator : '0.4.1',
// Debugging
timber : '4.7.1',
- fabric : '2.9.8@aar',
+ fabric : '2.9.9@aar',
// Unit testing
junit : '4.12',
- mockito : '2.23.4',
- mockitoKotlin : '2.0.0-RC1',
- truth : '0.42',
+ mockito : '2.27.0',
+ mockitoKotlin : '2.1.0',
+ truth : '0.44',
// UI testing
androidxTestRunner : '1.1.1',
androidxTest : '1.1.0',
- archTesting : '2.0.0'
+ archTesting : '2.0.1'
]
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt
index fbd3a45..071960c 100644
--- a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt
@@ -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
}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/ssl/SslManager.kt b/engine/src/main/java/com/afollestad/nocknock/engine/ssl/SslManager.kt
new file mode 100644
index 0000000..aceeb22
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/ssl/SslManager.kt
@@ -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())
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt
index deab303..cc80509 100644
--- a/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt
@@ -32,7 +32,7 @@ import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */
class BootReceiver : BroadcastReceiver(), KoinComponent {
- private val validationManager by inject()
+ private val validationManager by inject()
private val mainDispatcher by inject(name = MAIN_DISPATCHER)
private val ioDispatcher by inject(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()
}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt
similarity index 65%
rename from engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt
rename to engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt
index 87593fc..d47d616 100644
--- a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt
@@ -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
+ }
}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt
index 7e45f19..435ff89 100644
--- a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt
@@ -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.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()
- private val validationManager by inject()
+ private val validationManager by inject()
private val notificationManager by inject()
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
)
@@ -225,6 +232,7 @@ class ValidationJob : JobService() {
triesLeft: Int
) {
retryPolicy.triesLeft = triesLeft
+ retryPolicy.lastTryTimestamp = currentTimeMillis()
withContext(IO) {
database.retryPolicyDao()
.update(retryPolicy)
diff --git a/engine/src/test/java/com/afollestad/nocknock/engine/TestData.kt b/engine/src/test/java/com/afollestad/nocknock/engine/TestData.kt
new file mode 100644
index 0000000..cfe4a56
--- /dev/null
+++ b/engine/src/test/java/com/afollestad/nocknock/engine/TestData.kt
@@ -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 {
+ 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 {
+ on { insert(isA()) } doReturn 1
+ on { one(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(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 {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(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 {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(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 {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(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 {
+ on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(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()) } doReturn 1L
+ on { insert(isA>()) } 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
+ }
+}
diff --git a/engine/src/test/kotlin/com/afollestad/nocknock/engine/TestUtil.kt b/engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt
similarity index 74%
rename from engine/src/test/kotlin/com/afollestad/nocknock/engine/TestUtil.kt
rename to engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt
index af7113a..7255e9f 100644
--- a/engine/src/test/kotlin/com/afollestad/nocknock/engine/TestUtil.kt
+++ b/engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt
@@ -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()
val creator = it.getArgument(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)
+ }
+}
diff --git a/engine/src/test/kotlin/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt b/engine/src/test/java/com/afollestad/nocknock/engine/ValidationExecutorTest.kt
similarity index 55%
rename from engine/src/test/kotlin/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt
rename to engine/src/test/java/com/afollestad/nocknock/engine/ValidationExecutorTest.kt
index db859f4..dff3217 100644
--- a/engine/src/test/kotlin/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt
+++ b/engine/src/test/java/com/afollestad/nocknock/engine/ValidationExecutorTest.kt
@@ -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()
+ private val database = mockDatabase()
+ private val sslManager = mock {
+ on { clientForCertificate(any(), any(), any()) } doAnswer { inv ->
+ inv.getArgument(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 {
- val model1 = fakeModel()
- val job1 = fakeJob(model1.id)
- whenever(store.get()).doReturn(listOf(model1))
+ @Test fun ensureScheduledValidations_sitesAlreadyHaveJobs() = runBlocking {
+ 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())
- manager.ensureScheduledChecks()
+ manager.ensureScheduledValidations()
val jobCaptor = argumentCaptor()
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())
- manager.scheduleCheck(
+ manager.scheduleValidation(
site = model1,
rightNow = true
)
val jobCaptor = argumentCaptor()
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()
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())
- manager.scheduleCheck(
+ manager.scheduleValidation(
site = model1,
fromFinishingJob = true
)
val jobCaptor = argumentCaptor()
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 {
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 {
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 {
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()
+ val response = fakeResponse(200, "OK", "Hello World")
+
+ val call = mock {
+ 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 {
val response = fakeResponse(200, "OK", "Hello World")
val call = mock {
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 {
- 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
diff --git a/gradle.properties b/gradle.properties
index 9516cf5..c62d03d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -16,3 +16,6 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryErro
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
+
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 1c0396b..2128899 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
diff --git a/ic_web.png b/ic_web.png
index 509177f..d54b6ec 100644
Binary files a/ic_web.png and b/ic_web.png differ
diff --git a/mock/mock-google-services.json b/mock/mock-google-services.json
new file mode 100644
index 0000000..d9866d8
--- /dev/null
+++ b/mock/mock-google-services.json
@@ -0,0 +1,42 @@
+{
+ "project_info": {
+ "project_number": "123456789000",
+ "firebase_url": "https://mockproject-1234.firebaseio.com",
+ "project_id": "mockproject-1234",
+ "storage_bucket": "mockproject-1234.appspot.com"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063",
+ "android_client_info": {
+ "package_name": "com.afollestad.nocknock"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "123456789000-hjugbg6ud799v4c49dim8ce2usclthar.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "AIzbSzCn1N6LWIe6wthYyrgUUSAlUsdqMb-wvTo"
+ }
+ ],
+ "services": {
+ "analytics_service": {
+ "status": 1
+ },
+ "appinvite_service": {
+ "status": 1,
+ "other_platform_oauth_client": []
+ },
+ "ads_service": {
+ "status": 2
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
diff --git a/mock/mock.gradle b/mock/mock.gradle
new file mode 100644
index 0000000..2fa58ef
--- /dev/null
+++ b/mock/mock.gradle
@@ -0,0 +1,13 @@
+// This script must be applied in app/build.gradle for the paths here to work correctly
+
+def copyMockFilesNeeded() {
+ def srcGoogleServicesFile = file("../mock/mock-google-services.json")
+ def destGoogleServicesFile = file("google-services.json")
+ if (!destGoogleServicesFile.exists()) {
+ destGoogleServicesFile.write(srcGoogleServicesFile.text)
+ }
+}
+
+afterEvaluate {
+ copyMockFilesNeeded()
+}
\ No newline at end of file
diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/Channel.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/Channel.kt
index dcf9f76..7cad6ca 100644
--- a/notifications/src/main/java/com/afollestad/nocknock/notifications/Channel.kt
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/Channel.kt
@@ -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,
diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt
index e497151..84addbc 100644
--- a/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt
@@ -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) {
diff --git a/notifications/src/main/res/drawable/ic_notification.xml b/notifications/src/main/res/drawable/ic_notification_error.xml
similarity index 100%
rename from notifications/src/main/res/drawable/ic_notification.xml
rename to notifications/src/main/res/drawable/ic_notification_error.xml
diff --git a/notifications/src/main/res/drawable/ic_notification_success.xml b/notifications/src/main/res/drawable/ic_notification_success.xml
new file mode 100644
index 0000000..290fb76
--- /dev/null
+++ b/notifications/src/main/res/drawable/ic_notification_success.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/notifications/src/main/res/values/strings.xml b/notifications/src/main/res/values/strings.xml
index ba78f43..4462309 100644
--- a/notifications/src/main/res/values/strings.xml
+++ b/notifications/src/main/res/values/strings.xml
@@ -1,12 +1,18 @@
- Server Check Failures
+ Site Validation Failures
- 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.
+ Site Validation Success
+
+ Notifications for Nock Nock when a site validation passes when it previously had not.
+
+
Something\'s wrong! Tap for details.
+ Yay! No longer in trouble! Validation passed.
diff --git a/notifications/src/test/kotlin/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt b/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt
similarity index 75%
rename from notifications/src/test/kotlin/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt
rename to notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt
index 0f92bc0..d4709ab 100644
--- a/notifications/src/test/kotlin/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt
+++ b/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt
@@ -19,7 +19,8 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
-import com.afollestad.nocknock.notifications.Channel.CheckFailures
+import com.afollestad.nocknock.notifications.Channel.ValidationErrors
+import com.afollestad.nocknock.notifications.Channel.ValidationSuccess
import com.afollestad.nocknock.utilities.providers.CanNotifyModel
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider
@@ -41,12 +42,13 @@ import org.junit.Test
class NockNotificationManagerTest {
- private val appIconRes = 1024
private val somethingWentWrong = "something went wrong"
+ private val yay = "yay!"
private val stockManager = mock()
private val stringProvider = mock {
on { get(R.string.something_wrong) } doReturn somethingWentWrong
+ on { get(R.string.validation_passed) } doReturn yay
}
private val intentProvider = mock()
private val channelProvider = mock()
@@ -77,30 +79,42 @@ class NockNotificationManagerTest {
@Test fun createChannels() {
whenever(stringProvider.get(any())).doReturn("")
- val createdChannel = mock {
- on { this.id } doReturn CheckFailures.id
+ val errorChannel = mock {
+ on { this.id } doReturn ValidationErrors.id
+ }
+ val successChannel = mock {
+ on { this.id } doReturn ValidationSuccess.id
+ }
+
+ whenever(channelProvider.create(any(), any(), any(), any())).doAnswer { inv ->
+ val channelId = inv.getArgument(0)
+ when (channelId) {
+ ValidationErrors.id -> errorChannel
+ ValidationSuccess.id -> successChannel
+ else -> null
+ }
}
- whenever(channelProvider.create(any(), any(), any(), any()))
- .doReturn(createdChannel)
manager.createChannels()
val captor = argumentCaptor()
- verify(stockManager, times(1)).createNotificationChannel(captor.capture())
+ verify(stockManager, times(2)).createNotificationChannel(captor.capture())
- val channel = captor.allValues.single()
- assertThat(channel.id).isEqualTo(CheckFailures.id)
+ val channels = captor.allValues
+ assertThat(channels.size).isEqualTo(2)
+ assertThat(channels.first()).isEqualTo(successChannel)
+ assertThat(channels.last()).isEqualTo(errorChannel)
verifyNoMoreInteractions(stockManager)
}
- @Test fun postStatusNotification_appIsOpen() {
+ @Test fun postValidationSuccessNotification_appIsOpen() {
manager.setIsAppOpen(true)
- manager.postStatusNotification(fakeModel())
+ manager.postValidationSuccessNotification(fakeModel())
verifyNoMoreInteractions(stockManager)
}
- @Test fun postStatusNotification_appNotOpen() {
+ @Test fun postValidationSuccessNotification_appNotOpen() {
manager.setIsAppOpen(false)
val model = fakeModel()
@@ -111,15 +125,15 @@ class NockNotificationManagerTest {
val notification = mock()
whenever(
notificationProvider.create(
- CheckFailures.id,
+ ValidationErrors.id,
"Testing",
- somethingWentWrong,
+ yay,
pendingIntent,
- R.drawable.ic_notification
+ R.drawable.ic_notification_success
)
).doReturn(notification)
- manager.postStatusNotification(model)
+ manager.postValidationSuccessNotification(model)
verify(stockManager).notify(
"https://hello.com",
@@ -129,6 +143,13 @@ class NockNotificationManagerTest {
verifyNoMoreInteractions(stockManager)
}
+ @Test fun postValidationErrorNotification_appIsOpen() {
+ manager.setIsAppOpen(true)
+ manager.postValidationErrorNotification(fakeModel())
+
+ verifyNoMoreInteractions(stockManager)
+ }
+
@Test fun cancelStatusNotification() {
val model = fakeModel()
manager.cancelStatusNotification(model)
@@ -148,5 +169,7 @@ class NockNotificationManagerTest {
override fun notifyName() = "Testing"
override fun notifyTag() = "https://hello.com"
+
+ override fun notifyDescription() = "Hello, World!"
}
}
diff --git a/viewcomponents/build.gradle b/viewcomponents/build.gradle
index abbff09..a3ae3dc 100644
--- a/viewcomponents/build.gradle
+++ b/viewcomponents/build.gradle
@@ -18,7 +18,10 @@ dependencies {
implementation project(':common')
implementation project(':data')
+ api 'com.afollestad:vvalidator:' + versions.vvalidator
+
implementation 'androidx.appcompat:appcompat:' + versions.androidxCore
+ implementation 'com.google.android.material:material:' + versions.googleMaterial
api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle
api 'com.squareup.okhttp3:okhttp:' + versions.okHttp
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/ViewExt.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/ViewExt.kt
index feeef85..e543863 100644
--- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/ViewExt.kt
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/ViewExt.kt
@@ -17,37 +17,24 @@ package com.afollestad.nocknock.viewcomponents.ext
import android.view.View
import android.view.View.GONE
-import android.view.View.INVISIBLE
import android.view.View.VISIBLE
-import android.view.ViewTreeObserver
import androidx.annotation.DimenRes
+import com.afollestad.vvalidator.form.Condition
fun View.show() {
visibility = VISIBLE
}
-fun View.conceal() {
- visibility = INVISIBLE
-}
-
fun View.hide() {
visibility = GONE
}
fun View.showOrHide(show: Boolean) = if (show) show() else hide()
-fun View.onLayout(cb: () -> Unit) {
- if (this.viewTreeObserver.isAlive) {
- this.viewTreeObserver.addOnGlobalLayoutListener(
- object : ViewTreeObserver.OnGlobalLayoutListener {
- override fun onGlobalLayout() {
- cb()
- this@onLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
- }
- })
- }
-}
-
fun View.dimenFloat(@DimenRes res: Int) = resources.getDimension(res)
fun View.dimenInt(@DimenRes res: Int) = resources.getDimensionPixelSize(res)
+
+fun View.isVisibleCondition(): Condition = {
+ visibility == VISIBLE
+}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderItemLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderItemLayout.kt
new file mode 100644
index 0000000..4163bbc
--- /dev/null
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderItemLayout.kt
@@ -0,0 +1,65 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.viewcomponents.headers
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.LinearLayout
+import com.afollestad.nocknock.data.model.Header
+import com.afollestad.nocknock.utilities.ext.onTextChanged
+import com.afollestad.nocknock.viewcomponents.R
+import kotlinx.android.synthetic.main.header_stack_item_content.view.inputKey
+import kotlinx.android.synthetic.main.header_stack_item_content.view.inputValue
+
+/** @author Aidan Follestad (@afollestad) */
+class HeaderItemLayout(
+ context: Context,
+ attrs: AttributeSet? = null
+) : LinearLayout(context, attrs) {
+
+ private var header: Header? = null
+ private var stack: HeaderStackLayout? = null
+
+ init {
+ z
+ orientation = HORIZONTAL
+ inflate(context, R.layout.header_stack_item_content, this)
+ }
+
+ fun attachHeader(
+ newHeader: Header,
+ parentStack: HeaderStackLayout
+ ) {
+ this.header = newHeader
+ this.stack = parentStack
+
+ inputKey.run {
+ setText(newHeader.key)
+ onTextChanged {
+ header?.key = it.trim()
+ stack?.postLiveData()
+ }
+ }
+
+ inputValue.run {
+ setText(newHeader.value)
+ onTextChanged {
+ header?.value = it.trim()
+ stack?.postLiveData()
+ }
+ }
+ }
+}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt
new file mode 100644
index 0000000..6abff33
--- /dev/null
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt
@@ -0,0 +1,93 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.viewcomponents.headers
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.OnClickListener
+import android.widget.LinearLayout
+import androidx.lifecycle.MutableLiveData
+import com.afollestad.nocknock.data.model.Header
+import com.afollestad.nocknock.viewcomponents.R
+import kotlinx.android.synthetic.main.header_stack_item_content.view.btnRemove
+import kotlinx.android.synthetic.main.header_stack_item_content.view.inputKey
+import kotlinx.android.synthetic.main.header_stack_item_content.view.inputValue
+import kotlinx.android.synthetic.main.header_stack_layout.view.addHeader
+import kotlinx.android.synthetic.main.header_stack_layout.view.header_list as list
+
+/** @author Aidan Follestad (@afollestad) */
+class HeaderStackLayout(
+ context: Context,
+ attrs: AttributeSet? = null
+) : LinearLayout(context, attrs), OnClickListener {
+
+ private var data: MutableLiveData>? = null
+ private var headers = mutableListOf()
+
+ init {
+ orientation = VERTICAL
+ inflate(context, R.layout.header_stack_layout, this)
+ addHeader.setOnClickListener { addEntry(Header()) }
+ }
+
+ fun attach(data: MutableLiveData>) {
+ list.removeAllViews()
+ headers.clear()
+ data.value?.forEach(::addEntry)
+ this.data = data
+ }
+
+ fun postLiveData() = this.data?.postValue(headers)
+
+ override fun onClick(v: View) {
+ val index = v.tag as Int
+ check(index >= 0 || index < list.childCount) {
+ "Index $index is out of bounds in the header stack (size ${list.childCount})."
+ }
+ list.post {
+ list.removeViewAt(index)
+ headers.removeAt(index)
+ invalidateTags()
+ postLiveData()
+ }
+ }
+
+ private fun invalidateTags() {
+ for (i in 0 until list.childCount) {
+ val entry = list.getChildAt(i) as HeaderItemLayout
+ entry.btnRemove.tag = i
+ }
+ }
+
+ private fun addEntry(forHeader: Header) {
+ // Keep track of reference for posting future changes.
+ headers.add(forHeader)
+
+ val li = LayoutInflater.from(context)
+ val entry = li.inflate(R.layout.header_stack_item, list, false) as HeaderItemLayout
+ list.addView(entry.apply {
+ inputKey.setText(forHeader.key)
+ inputKey.post { entry.inputKey.requestFocus() }
+ attachHeader(forHeader, this@HeaderStackLayout)
+ inputValue.setText(forHeader.value)
+
+ btnRemove.tag = headers.size - 1
+ btnRemove.setOnClickListener(this@HeaderStackLayout)
+ })
+ }
+}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt
index 58bbe9d..2db60fe 100644
--- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt
@@ -19,17 +19,15 @@ import android.content.Context
import android.util.AttributeSet
import android.widget.ArrayAdapter
import android.widget.LinearLayout
-import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.WEEK
-import com.afollestad.nocknock.viewcomponents.R.array
-import com.afollestad.nocknock.viewcomponents.R.layout
+import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
-import com.afollestad.nocknock.viewcomponents.livedata.toViewError
+import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.validation_interval_layout.view.input
import kotlinx.android.synthetic.main.validation_interval_layout.view.spinner
@@ -48,18 +46,18 @@ class ValidationIntervalLayout(
init {
orientation = VERTICAL
- inflate(context, layout.validation_interval_layout, this)
+ inflate(context, R.layout.validation_interval_layout, this)
}
override fun onFinishInflate() {
super.onFinishInflate()
val spinnerAdapter = ArrayAdapter(
context,
- layout.list_item_spinner,
- resources.getStringArray(array.interval_options)
+ R.layout.list_item_spinner,
+ resources.getStringArray(R.array.interval_options)
)
spinnerAdapter.setDropDownViewResource(
- layout.list_item_spinner_dropdown
+ R.layout.list_item_spinner_dropdown
)
spinner.adapter = spinnerAdapter
}
@@ -67,8 +65,14 @@ class ValidationIntervalLayout(
fun attach(
valueData: MutableLiveData,
multiplierData: MutableLiveData,
- errorData: LiveData
+ form: Form
) {
+ form.input(input, name = "Interval") {
+ isNotEmpty().description(R.string.please_enter_check_interval)
+ length().greaterThan(0)
+ .description(R.string.check_interval_must_be_greater_zero)
+ }
+
input.attachLiveData(lifecycleOwner(), valueData)
spinner.attachLiveData(
lifecycleOwner = lifecycleOwner(),
@@ -92,10 +96,5 @@ class ValidationIntervalLayout(
}
}
)
- errorData.toViewError(lifecycleOwner(), this, ::setError)
- }
-
- private fun setError(error: String?) {
- input.error = error
}
}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt
index 5d32e69..1b1a29f 100644
--- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt
@@ -20,15 +20,17 @@ import android.util.AttributeSet
import android.widget.HorizontalScrollView
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
+import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.R.dimen
import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.dimenInt
+import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
-import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
+import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.javascript_input_layout.view.error_text
import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput
@@ -50,16 +52,25 @@ class JavaScriptInputLayout(
contentInset // bottom
)
elevation = dimenFloat(dimen.default_elevation)
- inflate(context, layout.javascript_input_layout, this)
+ inflate(context, R.layout.javascript_input_layout, this)
}
fun attach(
codeData: MutableLiveData,
- errorData: LiveData,
- visibility: LiveData
+ visibility: LiveData,
+ form: Form
) {
+ form.input(userInput, name = "Script") {
+ conditional(isVisibleCondition()) {
+ isNotEmpty().description(R.string.please_enter_javaScript)
+ }
+ onErrors { _, errors ->
+ val error = errors.firstOrNull()
+ setError(error.toString())
+ }
+ }
+
userInput.attachLiveData(lifecycleOwner(), codeData)
- errorData.toViewError(lifecycleOwner(), this, ::setError)
visibility.toViewVisibility(lifecycleOwner(), this)
}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/retrypolicy/RetryPolicyLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/retrypolicy/RetryPolicyLayout.kt
index 95e84d0..5a9375d 100644
--- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/retrypolicy/RetryPolicyLayout.kt
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/retrypolicy/RetryPolicyLayout.kt
@@ -24,6 +24,7 @@ import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.ext.asSafeInt
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
+import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.retry_policy_layout.view.minutes
import kotlinx.android.synthetic.main.retry_policy_layout.view.times
import kotlinx.android.synthetic.main.retry_policy_layout.view.retry_policy_desc as description
@@ -41,7 +42,8 @@ class RetryPolicyLayout(
fun attach(
timesData: MutableLiveData,
- minutesData: MutableLiveData
+ minutesData: MutableLiveData,
+ form: Form
) {
times.attachLiveData(lifecycleOwner(), timesData)
minutes.attachLiveData(lifecycleOwner(), minutesData)
@@ -50,6 +52,13 @@ class RetryPolicyLayout(
minutes.onTextChanged { invalidateDescriptionText() }
invalidateDescriptionText()
+
+ form.input(times, optional = true) {
+ isNumber().greaterThan(0)
+ }
+ form.input(minutes, optional = true) {
+ isNumber().greaterThan(0)
+ }
}
private fun invalidateDescriptionText() {
diff --git a/viewcomponents/src/main/res/drawable/ic_chevron_right.xml b/viewcomponents/src/main/res/drawable/ic_chevron_right.xml
new file mode 100644
index 0000000..3483bbb
--- /dev/null
+++ b/viewcomponents/src/main/res/drawable/ic_chevron_right.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/viewcomponents/src/main/res/drawable/ic_close.xml b/viewcomponents/src/main/res/drawable/ic_close.xml
new file mode 100644
index 0000000..6c28089
--- /dev/null
+++ b/viewcomponents/src/main/res/drawable/ic_close.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/viewcomponents/src/main/res/layout/header_stack_item.xml b/viewcomponents/src/main/res/layout/header_stack_item.xml
new file mode 100644
index 0000000..c87066f
--- /dev/null
+++ b/viewcomponents/src/main/res/layout/header_stack_item.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/viewcomponents/src/main/res/layout/header_stack_item_content.xml b/viewcomponents/src/main/res/layout/header_stack_item_content.xml
new file mode 100644
index 0000000..d963ccc
--- /dev/null
+++ b/viewcomponents/src/main/res/layout/header_stack_item_content.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/viewcomponents/src/main/res/layout/header_stack_layout.xml b/viewcomponents/src/main/res/layout/header_stack_layout.xml
new file mode 100644
index 0000000..e497e2a
--- /dev/null
+++ b/viewcomponents/src/main/res/layout/header_stack_layout.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/viewcomponents/src/main/res/layout/validation_interval_layout.xml b/viewcomponents/src/main/res/layout/validation_interval_layout.xml
index 2a16de4..af4592c 100644
--- a/viewcomponents/src/main/res/layout/validation_interval_layout.xml
+++ b/viewcomponents/src/main/res/layout/validation_interval_layout.xml
@@ -12,24 +12,32 @@
android:id="@+id/label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="@string/check_interval"
+ android:text="@string/validation_interval"
style="@style/NockText.SectionHeader"
/>
+
+
function validate(response) {
}
- Check Interval
+ Validation Interval
+ Every
Retry Policy
Retry
@@ -16,4 +17,14 @@
values. After retrying %1$d times over %2$d minutes with no success, you will get a notification.
+ Headers
+ Add Header…
+ Add HTTP headers to each request made to validate this site.
+ Header Name
+ Header Value
+
+ Please input a validation interval.
+ The validation interval must be greater than 0.
+ Please input a validation script.
+
diff --git a/viewcomponents/src/main/res/values/styles.xml b/viewcomponents/src/main/res/values/styles.xml
index f3be3fb..838adb7 100644
--- a/viewcomponents/src/main/res/values/styles.xml
+++ b/viewcomponents/src/main/res/values/styles.xml
@@ -9,6 +9,12 @@
- ?colorAccent
+
+
+
+
+
+