Compare commits

..

No commits in common. "master" and "0.8.2b" have entirely different histories.

129 changed files with 1377 additions and 2778 deletions

3
.editorconfig Normal file
View file

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

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

@ -0,0 +1,28 @@
(`[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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
### Guidelines ### Guidelines
1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`. 1. You must run the `spotlessApply` task before commiting, 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. 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. 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. 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.

2
.gitignore vendored
View file

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

37
.idea/misc.xml generated
View file

@ -5,42 +5,7 @@
<configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" /> <configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" />
</configurations> </configurations>
</component> </component>
<component name="NullableNotNullManager"> <component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<option name="myDefaultNullable" value="android.support.annotation.Nullable" />
<option name="myDefaultNotNull" value="android.support.annotation.NonNull" />
<option name="myNullables">
<value>
<list size="10">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.Nullable" />
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nullable" />
<item index="2" class="java.lang.String" itemvalue="javax.annotation.CheckForNull" />
<item index="3" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" />
<item index="4" class="java.lang.String" itemvalue="android.support.annotation.Nullable" />
<item index="5" class="java.lang.String" itemvalue="androidx.annotation.Nullable" />
<item index="6" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNullable" />
<item index="7" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.Nullable" />
<item index="8" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableDecl" />
<item index="9" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableType" />
</list>
</value>
</option>
<option name="myNotNulls">
<value>
<list size="9">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.NotNull" />
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nonnull" />
<item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.NonNull" />
<item index="3" class="java.lang.String" itemvalue="android.support.annotation.NonNull" />
<item index="4" class="java.lang.String" itemvalue="androidx.annotation.NonNull" />
<item index="5" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNonNull" />
<item index="6" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" />
<item index="7" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullDecl" />
<item index="8" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullType" />
</list>
</value>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

19
.travis.yml Normal file
View file

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

View file

@ -1,8 +1,9 @@
## Nock Nock ## Nock Nock
[![Build Status](https://travis-ci.org/afollestad/nock-nock.svg)](https://travis-ci.org/afollestad/nock-nock)
[![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html)
![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png) ![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcasemain3.png)
Nock Nock is a simple app which allows you to monitor your websites for maximum uptime. Nock Nock is a simple app which allows you to monitor your websites for maximum uptime.

View file

@ -4,6 +4,18 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-android-extensions'
apply plugin: 'io.fabric'
def getFabricApiKey() {
def propsFile = project.rootProject.file('local.properties')
if (!propsFile.exists()) {
return ""
}
Properties properties = new Properties()
properties.load(propsFile.newDataInputStream())
return properties.getProperty("fabric.apikey") ?: ""
}
android { android {
compileSdkVersion versions.compileSdk compileSdkVersion versions.compileSdk
buildToolsVersion versions.buildTools buildToolsVersion versions.buildTools
@ -14,15 +26,16 @@ android {
targetSdkVersion versions.compileSdk targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode versionCode versions.publishVersionCode
versionName versions.publishVersion versionName versions.publishVersion
manifestPlaceholders = [fabricKey:getFabricApiKey()]
} }
compileOptions { buildTypes {
sourceCompatibility 1.8 debug {
targetCompatibility 1.8 buildConfigField "String", "FABRIC_API_KEY", "\"\""
}
release {
buildConfigField "String", "FABRIC_API_KEY", "\"${getFabricApiKey()}\""
} }
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
} }
} }
@ -38,7 +51,6 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView
implementation 'com.google.android.material:material:' + versions.googleMaterial implementation 'com.google.android.material:material:' + versions.googleMaterial
implementation 'androidx.browser:browser:' + versions.androidxBrowser implementation 'androidx.browser:browser:' + versions.androidxBrowser
implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore
// Lifecycle // Lifecycle
kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle
@ -73,7 +85,3 @@ dependencies {
} }
apply from: '../spotless.gradle' apply from: '../spotless.gradle'
apply from: '../mock/mock.gradle'
apply plugin: "io.fabric"
apply plugin: 'com.google.gms.google-services'

View file

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

View file

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

View file

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

View file

@ -1,115 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.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<String>) -> Unit
/** @author Aidan Follestad (@afollestad) */
class TagAdapter(
private val listener: TagsListener
) : RecyclerView.Adapter<TagViewHolder>() {
private val tags = mutableListOf<String>()
private val checked = mutableListOf<Int>()
fun set(tags: List<String>) {
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<String> {
return mutableListOf<String>().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
}
)
}
}
}

View file

@ -20,7 +20,6 @@ import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R import com.afollestad.nocknock.R
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
@ -35,9 +34,8 @@ class AboutDialog : DialogFragment() {
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = activity ?: throw IllegalStateException("Oh no!") return MaterialDialog(activity!!)
return MaterialDialog(context) .title(R.string.about)
.title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME))
.positiveButton(R.string.dismiss) .positiveButton(R.string.dismiss)
.message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f) .message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
} }

View file

@ -23,9 +23,6 @@ import android.content.Context.NOTIFICATION_SERVICE
import androidx.room.Room.databaseBuilder import androidx.room.Room.databaseBuilder
import com.afollestad.nocknock.data.AppDatabase import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.Database1to2Migration import com.afollestad.nocknock.data.Database1to2Migration
import com.afollestad.nocknock.data.Database2to3Migration
import com.afollestad.nocknock.data.Database3to4Migration
import com.afollestad.nocknock.data.Database4to5Migration
import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS
import com.afollestad.nocknock.ui.main.MainActivity import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.utilities.ext.systemService import com.afollestad.nocknock.utilities.ext.systemService
@ -41,12 +38,7 @@ val mainModule = module {
single { single {
databaseBuilder(get(), AppDatabase::class.java, "NockNock.db") databaseBuilder(get(), AppDatabase::class.java, "NockNock.db")
.addMigrations( .addMigrations(Database1to2Migration())
Database1to2Migration(),
Database2to3Migration(),
Database3to4Migration(),
Database4to5Migration()
)
.build() .build()
} }

View file

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

View file

@ -1,26 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui
/** @author Aidan Follestad (@afollestad) */
enum class NightMode {
/** Night mode is on at the system level. */
ENABLED,
/** Night mode is off at the system level. */
DISABLED,
/** We don't know about night mode, fallback to custom impl. */
UNKNOWN
}

View file

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

View file

@ -25,8 +25,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.OnLifecycleEvent
import com.afollestad.nocknock.R import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.Header 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.Site
import com.afollestad.nocknock.data.model.SiteSettings import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status.WAITING import com.afollestad.nocknock.data.model.Status.WAITING
@ -36,10 +35,11 @@ import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.data.putSite import com.afollestad.nocknock.data.putSite
import com.afollestad.nocknock.engine.validation.ValidationExecutor import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.ui.ScopedViewModel import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.ext.MINUTE import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.livedata.map import com.afollestad.nocknock.utilities.livedata.map
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -49,14 +49,13 @@ import java.lang.System.currentTimeMillis
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
class AddSiteViewModel( class AddSiteViewModel(
private val database: AppDatabase, private val database: AppDatabase,
private val validationManager: ValidationExecutor, private val validationManager: ValidationManager,
mainDispatcher: CoroutineDispatcher, mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver { ) : ScopedViewModel(mainDispatcher), LifecycleObserver {
// Public properties // Public properties
val name = MutableLiveData<String>() val name = MutableLiveData<String>()
val tags = MutableLiveData<String>()
val url = MutableLiveData<String>() val url = MutableLiveData<String>()
val timeout = MutableLiveData<Int>() val timeout = MutableLiveData<Int>()
val validationMode = MutableLiveData<ValidationMode>() val validationMode = MutableLiveData<ValidationMode>()
@ -66,8 +65,6 @@ class AddSiteViewModel(
val checkIntervalUnit = MutableLiveData<Long>() val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>() val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>() val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>()
val certificateUri = MutableLiveData<String>()
@OnLifecycleEvent(ON_START) @OnLifecycleEvent(ON_START)
fun setDefaults() { fun setDefaults() {
@ -77,14 +74,24 @@ class AddSiteViewModel(
checkIntervalUnit.value = MINUTE checkIntervalUnit.value = MINUTE
retryPolicyMinutes.value = 0 retryPolicyMinutes.value = 0
retryPolicyMinutes.value = 0 retryPolicyMinutes.value = 0
tags.value = ""
headers.value = emptyList()
} }
// Private properties
private val isLoading = MutableLiveData<Boolean>() private val isLoading = MutableLiveData<Boolean>()
private val nameError = MutableLiveData<Int?>()
private val urlError = MutableLiveData<Int?>()
private val timeoutError = MutableLiveData<Int?>()
private val validationSearchTermError = MutableLiveData<Int?>()
private val validationScriptError = MutableLiveData<Int?>()
private val checkIntervalValueError = MutableLiveData<Int?>()
// Expose private properties or calculated properties
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading @CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onNameError(): LiveData<Int?> = nameError
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> { @CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map { return url.map {
val parsed = HttpUrl.parse(it) val parsed = HttpUrl.parse(it)
@ -92,6 +99,8 @@ class AddSiteViewModel(
} }
} }
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
@CheckResult fun onValidationModeDescription(): LiveData<Int> { @CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map { return validationMode.map {
when (it!!) { when (it!!) {
@ -102,9 +111,17 @@ class AddSiteViewModel(
} }
} }
@CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } @CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError
@CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } @CheckResult fun onValidationSearchTermVisibility() =
validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
@CheckResult fun onValidationScriptVisibility() =
validationMode.map { it == JAVASCRIPT }
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
// Actions // Actions
fun commit(done: () -> Unit) { fun commit(done: () -> Unit) {
@ -115,7 +132,7 @@ class AddSiteViewModel(
val storedModel = withContext(ioDispatcher) { val storedModel = withContext(ioDispatcher) {
database.putSite(newModel) database.putSite(newModel)
} }
validationManager.scheduleValidation( validationManager.scheduleCheck(
site = storedModel, site = storedModel,
rightNow = true, rightNow = true,
cancelPrevious = true cancelPrevious = true
@ -144,16 +161,75 @@ class AddSiteViewModel(
} }
private fun generateDbModel(): Site? { private fun generateDbModel(): Site? {
val timeout = timeout.value ?: 10_000 var errorCount = 0
val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
// 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 newSettings = SiteSettings( val newSettings = SiteSettings(
validationIntervalMs = getCheckIntervalMs(), validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!, validationMode = validationMode.value!!,
validationArgs = getValidationArgs(), validationArgs = getValidationArgs(),
networkTimeout = timeout, networkTimeout = timeout.value!!,
disabled = false, disabled = false
certificate = certificateUri.value?.toString()
) )
val newLastResult = ValidationResult( val newLastResult = ValidationResult(
@ -165,10 +241,7 @@ class AddSiteViewModel(
val retryPolicyTimes = retryPolicyTimes.value ?: 0 val retryPolicyTimes = retryPolicyTimes.value ?: 0
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
RetryPolicy( RetryPolicy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
} else { } else {
null null
} }
@ -177,11 +250,9 @@ class AddSiteViewModel(
id = 0, id = 0,
name = name.value!!.trim(), name = name.value!!.trim(),
url = url.value!!.trim(), url = url.value!!.trim(),
tags = cleanedTags,
settings = newSettings, settings = newSettings,
lastResult = newLastResult, lastResult = newLastResult,
retryPolicy = newRetryPolicy, retryPolicy = newRetryPolicy
headers = headers.value ?: emptyList()
) )
} }
} }

View file

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

View file

@ -21,19 +21,20 @@ import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItems import com.afollestad.materialdialogs.list.listItems
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import com.afollestad.nocknock.R import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.SiteAdapter import com.afollestad.nocknock.adapter.SiteAdapter
import com.afollestad.nocknock.adapter.TagAdapter
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.dialogs.AboutDialog import com.afollestad.nocknock.dialogs.AboutDialog
import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.DarkModeSwitchActivity import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.ui.NightMode.UNKNOWN
import com.afollestad.nocknock.utilities.providers.IntentProvider import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.utilities.ui.toast
import com.afollestad.nocknock.viewUrl
import com.afollestad.nocknock.viewUrlWithApp
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list import kotlinx.android.synthetic.main.activity_main.list
@ -42,7 +43,6 @@ import kotlinx.android.synthetic.main.include_app_bar.toolbar
import kotlinx.android.synthetic.main.include_empty_view.emptyText import kotlinx.android.synthetic.main.include_empty_view.emptyText
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlinx.android.synthetic.main.activity_main.tags_list as tagsList
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
class MainActivity : DarkModeSwitchActivity() { class MainActivity : DarkModeSwitchActivity() {
@ -53,7 +53,6 @@ class MainActivity : DarkModeSwitchActivity() {
internal val viewModel by viewModel<MainViewModel>() internal val viewModel by viewModel<MainViewModel>()
private lateinit var siteAdapter: SiteAdapter private lateinit var siteAdapter: SiteAdapter
private lateinit var tagAdapter: TagAdapter
private val statusUpdateReceiver by lazy { private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) { StatusUpdateIntentReceiver(application, intentProvider) {
@ -77,10 +76,6 @@ class MainActivity : DarkModeSwitchActivity() {
.observe(this, Observer { siteAdapter.set(it) }) .observe(this, Observer { siteAdapter.set(it) })
viewModel.onEmptyTextVisibility() viewModel.onEmptyTextVisibility()
.toViewVisibility(this, emptyText) .toViewVisibility(this, emptyText)
viewModel.onTags()
.observe(this, Observer { tagAdapter.set(it) })
viewModel.onTagsListVisibility()
.toViewVisibility(this, tagsList)
loadingProgress.observe(this, viewModel.onIsLoading()) loadingProgress.observe(this, viewModel.onIsLoading())
processIntent(intent) processIntent(intent)
@ -90,35 +85,24 @@ class MainActivity : DarkModeSwitchActivity() {
toolbar.run { toolbar.run {
inflateMenu(R.menu.menu_main) inflateMenu(R.menu.menu_main)
menu.findItem(R.id.dark_mode) menu.findItem(R.id.dark_mode)
.apply { .isChecked = isDarkMode()
if (getCurrentNightMode() == UNKNOWN) {
isChecked = isDarkMode()
} else {
isVisible = false
}
}
setOnMenuItemClickListener { item -> setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.about -> AboutDialog.show(this@MainActivity) R.id.about -> AboutDialog.show(this@MainActivity)
R.id.dark_mode -> toggleDarkMode() R.id.dark_mode -> toggleDarkMode()
R.id.support_me -> supportMe()
} }
return@setOnMenuItemClickListener true return@setOnMenuItemClickListener true
} }
} }
siteAdapter = SiteAdapter(this::onSiteSelected) siteAdapter = SiteAdapter(this::onSiteSelected)
list.run { list.run {
layoutManager = LinearLayoutManager(this@MainActivity) layoutManager = LinearLayoutManager(this@MainActivity)
adapter = siteAdapter adapter = siteAdapter
addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL)) addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL))
} }
tagAdapter = TagAdapter(viewModel::onTagSelection)
tagsList.run {
layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false)
adapter = tagAdapter
}
fab.setOnClickListener { addSite() } fab.setOnClickListener { addSite() }
} }
@ -137,8 +121,7 @@ class MainActivity : DarkModeSwitchActivity() {
listItems(R.array.site_long_options) { _, i, _ -> listItems(R.array.site_long_options) { _, i, _ ->
when (i) { when (i) {
0 -> viewModel.refreshSite(model) 0 -> viewModel.refreshSite(model)
1 -> addSiteForDuplication(model) 1 -> maybeRemoveSite(model)
2 -> maybeRemoveSite(model)
} }
} }
} }
@ -146,4 +129,20 @@ class MainActivity : DarkModeSwitchActivity() {
viewSite(model) viewSite(model)
} }
} }
private fun supportMe() {
MaterialDialog(this).show {
title(R.string.support_me)
message(R.string.support_me_message, html = true, lineHeightMultiplier = 1.4f)
listItemsSingleChoice(R.array.donation_options) { _, index, _ ->
when (index) {
0 -> viewUrl("https://paypal.me/AidanFollestad")
1 -> viewUrlWithApp("https://cash.me/\$afollestad", pkg = "com.squareup.cash")
2 -> viewUrlWithApp("https://venmo.com/afollestad", pkg = "com.venmo")
}
toast(R.string.thank_you)
}
positiveButton(R.string.next)
}
}
} }

View file

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

View file

@ -25,7 +25,7 @@ import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.allSites import com.afollestad.nocknock.data.allSites
import com.afollestad.nocknock.data.deleteSite import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.engine.validation.ValidationExecutor import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.ScopedViewModel import com.afollestad.nocknock.ui.ScopedViewModel
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -36,7 +36,7 @@ import kotlinx.coroutines.withContext
class MainViewModel( class MainViewModel(
private val database: AppDatabase, private val database: AppDatabase,
private val notificationManager: NockNotificationManager, private val notificationManager: NockNotificationManager,
private val validationManager: ValidationExecutor, private val validationManager: ValidationManager,
mainDispatcher: CoroutineDispatcher, mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver { ) : ScopedViewModel(mainDispatcher), LifecycleObserver {
@ -44,8 +44,6 @@ class MainViewModel(
private val sites = MutableLiveData<List<Site>>() private val sites = MutableLiveData<List<Site>>()
private val isLoading = MutableLiveData<Boolean>() private val isLoading = MutableLiveData<Boolean>()
private val emptyTextVisibility = MutableLiveData<Boolean>() private val emptyTextVisibility = MutableLiveData<Boolean>()
private val tags = MutableLiveData<List<String>>()
private val tagsListVisibility = MutableLiveData<Boolean>()
@CheckResult fun onSites(): LiveData<List<Site>> = sites @CheckResult fun onSites(): LiveData<List<Site>> = sites
@ -53,14 +51,8 @@ class MainViewModel(
@CheckResult fun onEmptyTextVisibility(): LiveData<Boolean> = emptyTextVisibility @CheckResult fun onEmptyTextVisibility(): LiveData<Boolean> = emptyTextVisibility
@CheckResult fun onTags(): LiveData<List<String>> = tags
@CheckResult fun onTagsListVisibility(): LiveData<Boolean> = tagsListVisibility
@OnLifecycleEvent(ON_RESUME) @OnLifecycleEvent(ON_RESUME)
fun onResume() = loadSites(emptyList()) fun onResume() = loadSites()
fun onTagSelection(tags: List<String>) = loadSites(tags)
fun postSiteUpdate(model: Site) { fun postSiteUpdate(model: Site) {
val currentSites = sites.value ?: return val currentSites = sites.value ?: return
@ -73,7 +65,7 @@ class MainViewModel(
} }
fun refreshSite(model: Site) { fun refreshSite(model: Site) {
validationManager.scheduleValidation( validationManager.scheduleCheck(
site = model, site = model,
rightNow = true, rightNow = true,
cancelPrevious = true cancelPrevious = true
@ -81,7 +73,7 @@ class MainViewModel(
} }
fun removeSite(model: Site) { fun removeSite(model: Site) {
validationManager.cancelScheduledValidation(model) validationManager.cancelCheck(model)
notificationManager.cancelStatusNotification(model) notificationManager.cancelStatusNotification(model)
scope.launch { scope.launch {
@ -102,56 +94,27 @@ class MainViewModel(
} }
} }
private fun loadSites(forTags: List<String>) { private fun loadSites() {
scope.launch { scope.launch {
notificationManager.cancelStatusNotifications() notificationManager.cancelStatusNotifications()
sites.value = listOf()
emptyTextVisibility.value = false emptyTextVisibility.value = false
isLoading.value = true isLoading.value = true
val unfiltered = withContext(ioDispatcher) { val result = withContext(ioDispatcher) {
database.allSites() 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 sites.value = result
ensureCheckJobs() ensureCheckJobs()
isLoading.value = false isLoading.value = false
emptyTextVisibility.value = result.isEmpty() emptyTextVisibility.value = result.isEmpty()
val tagsValues = pullOutTags(unfiltered)
tags.value = tagsValues
tagsListVisibility.value = tagsValues.isNotEmpty()
} }
} }
private suspend fun ensureCheckJobs() { private suspend fun ensureCheckJobs() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
validationManager.ensureScheduledValidations() validationManager.ensureScheduledChecks()
}
}
private fun pullOutTags(sites: List<Site>): List<String> {
return mutableListOf<String>().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()
} }
} }
} }

View file

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

View file

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

View file

@ -15,7 +15,7 @@
*/ */
package com.afollestad.nocknock.ui.viewsite package com.afollestad.nocknock.ui.viewsite
import com.afollestad.nocknock.data.model.RetryPolicy import com.afollestad.nocknock.data.RetryPolicy
import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.WAITING import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
@ -32,7 +32,6 @@ fun ViewSiteViewModel.setModel(site: Site) {
status.value = site.lastResult?.status ?: WAITING status.value = site.lastResult?.status ?: WAITING
name.value = site.name name.value = site.name
tags.value = site.tags
url.value = site.url url.value = site.url
timeout.value = settings.networkTimeout timeout.value = settings.networkTimeout
@ -54,12 +53,6 @@ fun ViewSiteViewModel.setModel(site: Site) {
setCheckInterval(settings.validationIntervalMs) setCheckInterval(settings.validationIntervalMs)
setRetryPolicy(site.retryPolicy) setRetryPolicy(site.retryPolicy)
headers.value = site.headers
if (settings.certificate == "null") {
certificateUri.value = ""
} else {
certificateUri.value = settings.certificate
}
this.disabled.value = settings.disabled this.disabled.value = settings.disabled
this.lastResult.value = site.lastResult this.lastResult.value = site.lastResult

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorAccent" android:state_pressed="false"/>
<item android:color="#FFFFFF" android:state_pressed="true"/>
</selector>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?colorAccent"/>
<stroke
android:color="@color/colorAccent_pressed"
android:width="1dp"/>
<corners android:radius="6dp"/>
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp"/>
</shape>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/colorAccent_pressed"/>
<stroke
android:color="?colorAccent"
android:width="1dp"/>
<corners android:radius="6dp"/>
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp"/>
</shape>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/checked_chip" android:state_pressed="false"/>
<item android:drawable="@drawable/checked_chip_pressed" android:state_pressed="true"/>
</selector>

View file

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

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?android:windowBackground"/>
<stroke
android:color="?colorAccent"
android:width="1dp"/>
<corners android:radius="6dp"/>
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp"/>
</shape>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/colorAccent_translucent"/>
<stroke
android:color="?colorAccent"
android:width="1dp"/>
<corners android:radius="6dp"/>
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp"/>
</shape>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/unchecked_chip" android:state_pressed="false"/>
<item android:drawable="@drawable/unchecked_chip_pressed" android:state_pressed="true"/>
</selector>

View file

@ -16,7 +16,6 @@
<include layout="@layout/include_app_bar"/> <include layout="@layout/include_app_bar"/>
<ScrollView <ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
> >
@ -25,61 +24,59 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingBottom="@dimen/content_inset_double" android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset" android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset" android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
> >
<TextView <com.google.android.material.textfield.TextInputLayout
android:layout_marginTop="0dp" android:id="@+id/nameTiLayout"
android:text="@string/site_name" android:layout_width="match_parent"
style="@style/InputForm.Header" android:layout_height="wrap_content"
/> android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset"
>
<EditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputName" android:id="@+id/inputName"
android:hint="@string/site_name_hint" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect" android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl" style="@style/NockText.Body"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/> />
<TextView </com.google.android.material.textfield.TextInputLayout>
android:text="@string/site_url"
style="@style/InputForm.Header"
/>
<EditText <com.google.android.material.textfield.TextInputLayout
android:id="@+id/urlTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_half"
>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputUrl" android:id="@+id/inputUrl"
android:hint="@string/site_url_hint" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_url"
android:inputType="textUri" android:inputType="textUri"
android:nextFocusDown="@+id/inputTags" style="@style/NockText.Body"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/> />
</com.google.android.material.textfield.TextInputLayout>
<TextView <TextView
android:id="@+id/textUrlWarning" android:id="@+id/textUrlWarning"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:text="@string/warning_http_url" android:text="@string/warning_http_url"
android:visibility="gone" android:visibility="gone"
style="@style/InputForm.FieldNote" style="@style/NockText.Footnote"
/>
<TextView
android:text="@string/site_tags"
style="@style/InputForm.Header"
/>
<EditText
android:id="@+id/inputTags"
android:digits=",.?-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "
android:hint="@string/site_tags_hint"
android:inputType="text|textCapWords"
android:nextFocusDown="@+id/inputUrl"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/> />
<include layout="@layout/include_divider"/> <include layout="@layout/include_divider"/>
@ -91,10 +88,35 @@
android:layout_marginTop="@dimen/content_inset" android:layout_marginTop="@dimen/content_inset"
/> />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_less"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:layout_marginTop="@dimen/content_inset_quarter"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<TextView <TextView
android:id="@+id/responseValidationLabel" android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_more"
android:text="@string/response_validation_mode" android:text="@string/response_validation_mode"
style="@style/InputForm.Header" style="@style/NockText.SectionHeader"
/> />
<Spinner <Spinner
@ -123,7 +145,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset" android:layout_marginBottom="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_half" android:layout_marginTop="@dimen/content_inset_half"
android:background="?scriptLayoutBackground" android:background="@color/lighterGray"
/> />
<TextView <TextView
@ -136,8 +158,6 @@
style="@style/NockText.Body.Light" style="@style/NockText.Body.Light"
/> />
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout <com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
android:id="@+id/retryPolicyLayout" android:id="@+id/retryPolicyLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -145,66 +165,13 @@
android:layout_marginTop="@dimen/content_inset_more" android:layout_marginTop="@dimen/content_inset_more"
/> />
<TextView <com.google.android.material.button.MaterialButton
android:layout_marginTop="@dimen/content_inset" android:id="@+id/doneBtn"
android:text="@string/response_timeout"
style="@style/InputForm.Header"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/ssl_certificate"
style="@style/NockText.SectionHeader"
/>
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:layout_marginTop="@dimen/content_inset_double"
> android:text="@string/add_site"
style="@style/AccentButton"
<EditText
android:id="@+id/sslCertificateInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:hint="@string/ssl_certificate_automatic"
android:inputType="textUri"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<Button
android:id="@+id/sslCertificateBrowse"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:text="@string/ssl_certificate_browse"
style="@style/AccentTextButton"
/>
</LinearLayout>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
android:id="@+id/headersLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
/> />
</LinearLayout> </LinearLayout>

View file

@ -17,19 +17,6 @@
<include layout="@layout/include_app_bar"/> <include layout="@layout/include_app_bar"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tags_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingBottom="@dimen/content_inset_half"
android:paddingEnd="@dimen/content_inset"
android:paddingStart="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
android:scrollbars="none"
android:visibility="gone"
/>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/list" android:id="@+id/list"
android:layout_width="match_parent" android:layout_width="match_parent"

View file

@ -26,30 +26,15 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:paddingBottom="@dimen/content_inset_double" android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset" android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset" android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_less" android:paddingTop="@dimen/content_inset"
> >
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Header"
/>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_quarter"
android:orientation="horizontal" android:orientation="horizontal"
> >
@ -70,13 +55,24 @@
android:orientation="vertical" android:orientation="vertical"
> >
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:singleLine="true"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
<EditText <EditText
android:id="@+id/inputUrl" android:id="@+id/inputUrl"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:hint="@string/site_url" android:hint="@string/site_url"
android:inputType="textUri" android:inputType="textUri"
android:nextFocusDown="@+id/inputTags"
android:singleLine="true" android:singleLine="true"
android:transitionName="site_url" android:transitionName="site_url"
tools:ignore="Autofill,UnusedAttribute" tools:ignore="Autofill,UnusedAttribute"
@ -95,19 +91,6 @@
style="@style/NockText.Footnote" style="@style/NockText.Footnote"
/> />
<EditText
android:id="@+id/inputTags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits=",.?-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "
android:hint="@string/site_tags_hint"
android:imeOptions="actionNext"
android:inputType="text|textCapWords"
android:singleLine="true"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@ -126,6 +109,35 @@
android:layout_marginTop="@dimen/content_inset" android:layout_marginTop="@dimen/content_inset"
/> />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:layout_marginTop="@dimen/content_inset_quarter"
android:maxLength="8"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
android:background="?dividerColor"
/>
<TextView <TextView
android:id="@+id/responseValidationLabel" android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -161,7 +173,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset" android:layout_marginBottom="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_half" android:layout_marginTop="@dimen/content_inset_half"
android:background="?scriptLayoutBackground" android:background="@color/lighterGray"
/> />
<TextView <TextView
@ -174,13 +186,6 @@
style="@style/NockText.Body.Light" style="@style/NockText.Body.Light"
/> />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
android:background="?dividerColor"
/>
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout <com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
android:id="@+id/retryPolicyLayout" android:id="@+id/retryPolicyLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -188,76 +193,6 @@
android:layout_marginTop="@dimen/content_inset" android:layout_marginTop="@dimen/content_inset"
/> />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:layout_marginTop="@dimen/content_inset_quarter"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/ssl_certificate"
style="@style/NockText.SectionHeader"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
<EditText
android:id="@+id/sslCertificateInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:hint="@string/ssl_certificate_automatic"
android:inputType="textUri"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<Button
android:id="@+id/sslCertificateBrowse"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:text="@string/ssl_certificate_browse"
style="@style/AccentTextButton"
/>
</LinearLayout>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
android:id="@+id/headersLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
/>
<include layout="@layout/include_divider"/> <include layout="@layout/include_divider"/>
<TextView <TextView
@ -294,6 +229,24 @@
style="@style/NockText.Body" style="@style/NockText.Body"
/> />
<com.google.android.material.button.MaterialButton
android:id="@+id/doneBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_double"
android:text="@string/save_changes"
style="@style/AccentButton"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/disableChecksButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_quarter"
android:text="@string/disable_automatic_checks"
style="@style/PrimaryDarkButton"
/>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.AppCompatTextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/content_inset_half"
android:background="@drawable/unchecked_chip_selector"
android:textColor="?colorAccent"
app:textAllCaps="true"
tools:text="Testing"
style="@style/NockText.Body"
/>

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,42 +1,35 @@
<resources> <resources>
<string name="app_name">Nock Nock</string> <string name="app_name">Nock Nock</string>
<string name="app_name_x">Nock Nock %1$s</string>
<string name="no_sites_added">No sites added!</string> <string name="no_sites_added">No sites added!</string>
<string name="about">About</string> <string name="about">About</string>
<string name="about_body"><![CDATA[ <string name="about_body"><![CDATA[
A simple app designed by <b>Aidan Follestad</b>.<br/> <b>Nock Nock</b>, a simple app designed by <b>Aidan Follestad</b>.<br/>
<a href=\'https://af.codes\'>Website</a>&nbsp;&nbsp; <a href=\'https://af.codes\'>Website</a>&nbsp;&nbsp;
<a href=\'https://twitter.com/afollestad\'>Twitter</a>&nbsp;&nbsp; <a href=\'https://twitter.com/afollestad\'>Twitter</a>&nbsp;&nbsp;
<a href=\'https://github.com/afollestad\'>GitHub</a>&nbsp;&nbsp; <a href=\'https://github.com/afollestad\'>GitHub</a>&nbsp;&nbsp;
<a href=\'https://www.linkedin.com/in/afollestad\'>LinkedIn</a> <a href=\'https://www.linkedin.com/in/afollestad\'>LinkedIn</a>
<br/><br/><i>Nock Nock is open source! Check out the <a href=\'https://github.com/afollestad/nock-nock\'>GitHub page</a>!</i> <br/><br/><i>Nock Nock is open source! Check out the <a href=\'https://github.com/afollestad/nock-nock\'>GitHub page</a>!</i>
<br/>Icon by <a href=\'https://plus.google.com/+KevinAguilarC\'>Kevin Aguilar</a> of <b>221 Pixels</b>. <br/>Icon by <a href=\'https://plus.google.com/+KevinAguilarC\'>Kevin Aguilar</a> of <b>221 Pixels</b>.
<br/>View the <a href=\'https://af.codes/privacypolicies/nocknock.html\'>Privacy Policy</a>.
]]></string> ]]></string>
<string name="dark_mode">Dark Mode</string> <string name="dark_mode">Dark Mode</string>
<string name="dismiss">Dismiss</string> <string name="dismiss">Dismiss</string>
<string name="add_site">Add Site</string> <string name="add_site">Add Site</string>
<string name="site_name">Site Name</string> <string name="site_name">Site Name</string>
<string name="site_name_hint">Site display name</string>
<string name="site_url">Site URL</string> <string name="site_url">Site URL</string>
<string name="site_url_hint">https://yoursite.com</string>
<string name="site_tags">Site Tags</string>
<string name="site_tags_hint">e.g. One,Two,Three</string>
<string name="site_tags_hint_full">Tags (e.g. One,Two,Three)</string>
<string name="please_enter_name">Please enter a name!</string> <string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</string> <string name="please_enter_url">Please enter a URL.</string>
<string name="please_enter_valid_url">Please enter a valid URL.</string> <string name="please_enter_valid_url">Please enter a valid URL.</string>
<string name="please_enter_check_interval">Please input a validation interval.</string>
<string name="please_enter_search_term">Please input a search term.</string> <string name="please_enter_search_term">Please input a search term.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
<string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string> <string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string>
<string name="please_enter_validCertUri">Certificate should be a valid file or content URI.</string>
<string name="options">Options</string> <string name="options">Options</string>
<string name="remove_site">Remove Site</string> <string name="remove_site">Remove Site</string>
<string name="duplicate_and_modify">Duplicate and Modify</string>
<string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string> <string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string>
<string name="remove">Remove</string> <string name="remove">Remove</string>
<string name="save_changes">Save Changes</string> <string name="save_changes">Save Changes</string>
@ -50,8 +43,8 @@
<string name="disable_automatic_checks">Disable Automatic Validation</string> <string name="disable_automatic_checks">Disable Automatic Validation</string>
<string name="disable_automatic_checks_prompt"><![CDATA[ <string name="disable_automatic_checks_prompt"><![CDATA[
Disable automatic validation for <b>%1$s</b>? The site will not be checked in the background Disable automatic validation for <b>%1$s</b>? The site will not be checked in the background
until you re-enable validation by tapping the checkmark (Save) icon. You can still manually until you re-enable validation for it. You can still manually perform validation by tapping the
perform validation by tapping the Refresh icon at the top of this page. Refresh icon at the top of this page.
]]></string> ]]></string>
<string name="disable">Disable</string> <string name="disable">Disable</string>
<string name="renable_and_save_changes">Enable Auto Validation &amp; Save Changes</string> <string name="renable_and_save_changes">Enable Auto Validation &amp; Save Changes</string>
@ -59,10 +52,6 @@
<string name="response_timeout">Network Response Timeout (ms)</string> <string name="response_timeout">Network Response Timeout (ms)</string>
<string name="response_timeout_default">10000</string> <string name="response_timeout_default">10000</string>
<string name="ssl_certificate">SSL Certificate</string>
<string name="ssl_certificate_automatic">(Automatic)</string>
<string name="ssl_certificate_browse">Browse</string>
<string name="refresh_status">Refresh Status</string> <string name="refresh_status">Refresh Status</string>
<string name="warning_http_url"> <string name="warning_http_url">
@ -85,6 +74,14 @@
exception to pass custom error messages to Nock Nock. exception to pass custom error messages to Nock Nock.
</string> </string>
<string name="support_me">Donate</string>
<string name="support_me_message"><![CDATA[
<b>Nock Nock</b> was created and is maintained by one person. Donations are <b>much</b>
appreciated and encourage continued support.
]]></string>
<string name="thank_you">Thank you very much!</string>
<string name="next">Next</string>
<string name="install_web_browser">Please install a web browser app, such as Google Chrome.</string> <string name="install_web_browser">Please install a web browser app, such as Google Chrome.</string>
</resources> </resources>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

BIN
art/showcasemain3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@
<application> <application>
<uses-library <uses-library
android:name="androidx.test.runner" android:name="android.test.runner"
android:required="false"/> android:required="false"/>
</application> </application>
</manifest> </manifest>

View file

@ -21,8 +21,6 @@ import android.content.Context
import androidx.room.Room.inMemoryDatabaseBuilder import androidx.room.Room.inMemoryDatabaseBuilder
import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.core.app.ApplicationProvider.getApplicationContext
import androidx.test.runner.AndroidJUnit4 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.Site
import com.afollestad.nocknock.data.model.SiteSettings import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status.ERROR import com.afollestad.nocknock.data.model.Status.ERROR
@ -48,7 +46,6 @@ class AppDatabaseTest() {
private lateinit var settingsDao: SiteSettingsDao private lateinit var settingsDao: SiteSettingsDao
private lateinit var resultsDao: ValidationResultsDao private lateinit var resultsDao: ValidationResultsDao
private lateinit var retryDao: RetryPolicyDao private lateinit var retryDao: RetryPolicyDao
private lateinit var headerDao: HeaderDao
@Before fun setup() { @Before fun setup() {
val context = getApplicationContext<Context>() val context = getApplicationContext<Context>()
@ -57,12 +54,13 @@ class AppDatabaseTest() {
settingsDao = db.siteSettingsDao() settingsDao = db.siteSettingsDao()
resultsDao = db.validationResultsDao() resultsDao = db.validationResultsDao()
retryDao = db.retryPolicyDao() retryDao = db.retryPolicyDao()
headerDao = db.headerDao()
} }
@After @After
@Throws(IOException::class) @Throws(IOException::class)
fun destroy() = db.close() fun destroy() {
db.close()
}
// SiteDao // SiteDao
@ -70,11 +68,9 @@ class AppDatabaseTest() {
val model1 = Site( val model1 = Site(
name = "Test 1", name = "Test 1",
url = "https://test1.com", url = "https://test1.com",
tags = "",
settings = null, settings = null,
lastResult = null, lastResult = null,
retryPolicy = null, retryPolicy = null
headers = emptyList()
) )
val newId1 = sitesDao.insert(model1) val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0) assertThat(newId1).isGreaterThan(0)
@ -82,11 +78,9 @@ class AppDatabaseTest() {
val model2 = Site( val model2 = Site(
name = "Test 2", name = "Test 2",
url = "https://test2.com", url = "https://test2.com",
tags = "",
settings = null, settings = null,
lastResult = null, lastResult = null,
retryPolicy = null, retryPolicy = null
headers = emptyList()
) )
val newId2 = sitesDao.insert(model2) val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1) assertThat(newId2).isGreaterThan(newId1)
@ -101,11 +95,9 @@ class AppDatabaseTest() {
val model = Site( val model = Site(
name = "Test", name = "Test",
url = "https://test.com", url = "https://test.com",
tags = "",
settings = null, settings = null,
lastResult = null, lastResult = null,
retryPolicy = null, retryPolicy = null
headers = emptyList()
) )
val newId = sitesDao.insert(model) val newId = sitesDao.insert(model)
assertThat(newId).isGreaterThan(0) assertThat(newId).isGreaterThan(0)
@ -118,11 +110,9 @@ class AppDatabaseTest() {
val initialModel = Site( val initialModel = Site(
name = "Test 1", name = "Test 1",
url = "https://test1.com", url = "https://test1.com",
tags = "",
settings = null, settings = null,
lastResult = null, lastResult = null,
retryPolicy = null, retryPolicy = null
headers = emptyList()
) )
val newId = sitesDao.insert(initialModel) val newId = sitesDao.insert(initialModel)
assertThat(newId).isGreaterThan(0) assertThat(newId).isGreaterThan(0)
@ -144,11 +134,9 @@ class AppDatabaseTest() {
val model1 = Site( val model1 = Site(
name = "Test 1", name = "Test 1",
url = "https://test1.com", url = "https://test1.com",
tags = "",
settings = null, settings = null,
lastResult = null, lastResult = null,
retryPolicy = null, retryPolicy = null
headers = emptyList()
) )
val newId1 = sitesDao.insert(model1) val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0) assertThat(newId1).isGreaterThan(0)
@ -156,11 +144,9 @@ class AppDatabaseTest() {
val model2 = Site( val model2 = Site(
name = "Test 2", name = "Test 2",
url = "https://test2.com", url = "https://test2.com",
tags = "",
settings = null, settings = null,
lastResult = null, lastResult = null,
retryPolicy = null, retryPolicy = null
headers = emptyList()
) )
val newId2 = sitesDao.insert(model2) val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1) assertThat(newId2).isGreaterThan(newId1)
@ -181,8 +167,7 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE, validationMode = STATUS_CODE,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000, networkTimeout = 10000
certificate = null
) )
val newId = settingsDao.insert(model) val newId = settingsDao.insert(model)
assertThat(newId).isEqualTo(1) assertThat(newId).isEqualTo(1)
@ -200,8 +185,7 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE, validationMode = STATUS_CODE,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000, networkTimeout = 10000
certificate = null
) )
) )
@ -229,8 +213,7 @@ class AppDatabaseTest() {
validationMode = STATUS_CODE, validationMode = STATUS_CODE,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000, networkTimeout = 10000
certificate = null
) )
) )
@ -308,7 +291,7 @@ class AppDatabaseTest() {
val newId = retryDao.insert(model) val newId = retryDao.insert(model)
assertThat(newId).isEqualTo(1) assertThat(newId).isEqualTo(1)
val finalModel = retryDao.forSite(newId) val finalModel = resultsDao.forSite(newId)
.single() .single()
assertThat(finalModel).isEqualTo(model.copy(siteId = newId)) assertThat(finalModel).isEqualTo(model.copy(siteId = newId))
} }
@ -350,78 +333,6 @@ class AppDatabaseTest() {
assertThat(retryDao.forSite(1)).isEmpty() 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 // Extension Methods
@Test fun extension_put_and_allSites() { @Test fun extension_put_and_allSites() {
@ -431,30 +342,9 @@ class AppDatabaseTest() {
val allSites = db.allSites() val allSites = db.allSites()
assertThat(allSites.size).isEqualTo(3) assertThat(allSites.size).isEqualTo(3)
assertThat(allSites[0]).isEqualTo( assertThat(allSites[0]).isEqualTo(MOCK_MODEL_1)
MOCK_MODEL_1.copy( assertThat(allSites[1]).isEqualTo(MOCK_MODEL_2)
headers = listOf( assertThat(allSites[2]).isEqualTo(MOCK_MODEL_3)
MOCK_MODEL_1.headers.first().copy(id = 1),
MOCK_MODEL_1.headers.last().copy(id = 2)
)
)
)
assertThat(allSites[1]).isEqualTo(
MOCK_MODEL_2.copy(
headers = listOf(
MOCK_MODEL_2.headers.first().copy(id = 3),
MOCK_MODEL_2.headers.last().copy(id = 4)
)
)
)
assertThat(allSites[2]).isEqualTo(
MOCK_MODEL_3.copy(
headers = listOf(
MOCK_MODEL_3.headers.first().copy(id = 5),
MOCK_MODEL_3.headers.last().copy(id = 6)
)
)
)
} }
@Test fun extension_put_getSite() { @Test fun extension_put_getSite() {
@ -489,25 +379,12 @@ class AppDatabaseTest() {
count = 4, count = 4,
minutes = 8 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( val updatedModel = modelToUpdate.copy(
name = "Oijrfouhef", name = "Oijrfouhef",
url = "https://iojfdfsdk.io", url = "https://iojfdfsdk.io",
settings = updatedSettings, settings = updatedSettings,
lastResult = updatedValidationResult, lastResult = updatedValidationResult,
retryPolicy = updatedRetryPolicy, retryPolicy = updatedRetryPolicy
headers = updatedHeaders
) )
db.updateSite(updatedModel) db.updateSite(updatedModel)
@ -516,8 +393,6 @@ class AppDatabaseTest() {
assertThat(finalSite.settings).isEqualTo(updatedSettings) assertThat(finalSite.settings).isEqualTo(updatedSettings)
assertThat(finalSite.lastResult).isEqualTo(updatedValidationResult) assertThat(finalSite.lastResult).isEqualTo(updatedValidationResult)
assertThat(finalSite.retryPolicy).isEqualTo(updatedRetryPolicy) assertThat(finalSite.retryPolicy).isEqualTo(updatedRetryPolicy)
assertThat(finalSite.headers.first()).isEqualTo(updatedHeaders.first())
assertThat(finalSite.headers.last()).isEqualTo(updatedHeaders.last())
assertThat(finalSite).isEqualTo(updatedModel) assertThat(finalSite).isEqualTo(updatedModel)
} }
@ -527,7 +402,7 @@ class AppDatabaseTest() {
db.putSite(MOCK_MODEL_3) db.putSite(MOCK_MODEL_3)
val allSites = db.allSites() val allSites = db.allSites()
db.deleteSite(allSites[1]) db.deleteSite(MOCK_MODEL_2)
val remainingSettings = settingsDao.all() val remainingSettings = settingsDao.all()
assertThat(remainingSettings.size).isEqualTo(2) assertThat(remainingSettings.size).isEqualTo(2)
@ -543,12 +418,5 @@ class AppDatabaseTest() {
assertThat(remainingRetryPolicies.size).isEqualTo(2) assertThat(remainingRetryPolicies.size).isEqualTo(2)
assertThat(remainingRetryPolicies[0]).isEqualTo(allSites[0].retryPolicy!!) assertThat(remainingRetryPolicies[0]).isEqualTo(allSites[0].retryPolicy!!)
assertThat(remainingRetryPolicies[1]).isEqualTo(allSites[2].retryPolicy!!) assertThat(remainingRetryPolicies[1]).isEqualTo(allSites[2].retryPolicy!!)
val remainingHeaders = headerDao.all()
assertThat(remainingHeaders.size).isEqualTo(4)
assertThat(remainingHeaders[0]).isEqualTo(allSites[0].headers.first())
assertThat(remainingHeaders[1]).isEqualTo(allSites[0].headers.last())
assertThat(remainingHeaders[2]).isEqualTo(allSites[2].headers.first())
assertThat(remainingHeaders[3]).isEqualTo(allSites[2].headers.last())
} }
} }

View file

@ -15,8 +15,6 @@
*/ */
package com.afollestad.nocknock.data 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.Site
import com.afollestad.nocknock.data.model.SiteSettings import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status import com.afollestad.nocknock.data.model.Status
@ -35,8 +33,7 @@ fun fakeSettingsModel(
validationMode = validationMode, validationMode = validationMode,
validationArgs = null, validationArgs = null,
disabled = false, disabled = false,
networkTimeout = 10000, networkTimeout = 10000
certificate = null
) )
fun fakeResultModel( fun fakeResultModel(
@ -60,20 +57,13 @@ fun fakeRetryPolicy(
minutes = minutes 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( fun fakeModel(id: Long) = Site(
id = id, id = id,
name = "Test", name = "Test",
url = "https://test.com", url = "https://test.com",
tags = "",
settings = fakeSettingsModel(id), settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id), lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id), retryPolicy = fakeRetryPolicy(id)
headers = fakeHeaders(id)
) )
val MOCK_MODEL_1 = fakeModel(1) val MOCK_MODEL_1 = fakeModel(1)

View file

@ -19,8 +19,6 @@ import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.afollestad.nocknock.data.model.Converters 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.Site
import com.afollestad.nocknock.data.model.SiteSettings import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.ValidationResult import com.afollestad.nocknock.data.model.ValidationResult
@ -28,13 +26,12 @@ import com.afollestad.nocknock.data.model.ValidationResult
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
@Database( @Database(
entities = [ entities = [
Header::class,
RetryPolicy::class, RetryPolicy::class,
ValidationResult::class, ValidationResult::class,
SiteSettings::class, SiteSettings::class,
Site::class Site::class
], ],
version = 5, version = 2,
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -47,8 +44,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun validationResultsDao(): ValidationResultsDao abstract fun validationResultsDao(): ValidationResultsDao
abstract fun retryPolicyDao(): RetryPolicyDao abstract fun retryPolicyDao(): RetryPolicyDao
abstract fun headerDao(): HeaderDao
} }
/** /**
@ -65,12 +60,10 @@ fun AppDatabase.allSites(): List<Site> {
.singleOrNull() .singleOrNull()
val retryPolicy = retryPolicyDao().forSite(it.id) val retryPolicy = retryPolicyDao().forSite(it.id)
.singleOrNull() .singleOrNull()
val headers = headerDao().forSite(it.id)
return@map it.copy( return@map it.copy(
settings = settings, settings = settings,
lastResult = lastResult, lastResult = lastResult,
retryPolicy = retryPolicy, retryPolicy = retryPolicy
headers = headers
) )
} }
} }
@ -89,12 +82,10 @@ fun AppDatabase.getSite(id: Long): Site? {
.singleOrNull() .singleOrNull()
val retryPolicy = retryPolicyDao().forSite(id) val retryPolicy = retryPolicyDao().forSite(id)
.singleOrNull() .singleOrNull()
val headers = headerDao().forSite(id)
return result.copy( return result.copy(
settings = settings, settings = settings,
lastResult = lastResult, lastResult = lastResult,
retryPolicy = retryPolicy, retryPolicy = retryPolicy
headers = headers
) )
} }
@ -109,19 +100,14 @@ fun AppDatabase.putSite(site: Site): Site {
val settingsWithSiteId = settings.copy(siteId = newId) val settingsWithSiteId = settings.copy(siteId = newId)
val lastResultWithSiteId = site.lastResult?.copy(siteId = newId) val lastResultWithSiteId = site.lastResult?.copy(siteId = newId)
val retryPolicyWithSiteId = site.retryPolicy?.copy(siteId = newId) val retryPolicyWithSiteId = site.retryPolicy?.copy(siteId = newId)
val headersWithSiteId = site.headers.map { it.copy(siteId = newId) }
siteSettingsDao().insert(settingsWithSiteId) siteSettingsDao().insert(settingsWithSiteId)
lastResultWithSiteId?.let { validationResultsDao().insert(it) } lastResultWithSiteId?.let { validationResultsDao().insert(it) }
retryPolicyWithSiteId?.let { retryPolicyDao().insert(it) } retryPolicyWithSiteId?.let { retryPolicyDao().insert(it) }
headerDao().insert(headersWithSiteId)
return site.copy( return site.copy(
id = newId, id = newId,
settings = settingsWithSiteId, settings = settingsWithSiteId
lastResult = lastResultWithSiteId,
retryPolicy = retryPolicyWithSiteId,
headers = headersWithSiteId
) )
} }
@ -165,13 +151,6 @@ fun AppDatabase.updateSite(site: Site) {
retryPolicyDao().insert(retryPolicy) 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))
}
} }
/** /**
@ -183,9 +162,5 @@ fun AppDatabase.deleteSite(site: Site) {
site.settings?.let { siteSettingsDao().delete(it) } site.settings?.let { siteSettingsDao().delete(it) }
site.lastResult?.let { validationResultsDao().delete(it) } site.lastResult?.let { validationResultsDao().delete(it) }
site.retryPolicy?.let { retryPolicyDao().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) siteDao().delete(site)
} }

View file

@ -27,45 +27,7 @@ class Database1to2Migration : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL( database.execSQL(
"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)" "CREATE TABLE `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes 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")
}
}

View file

@ -1,47 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy.FAIL
import androidx.room.Query
import androidx.room.Update
import com.afollestad.nocknock.data.model.Header
/** @author Aidan Follestad (@afollestad) */
@Dao
interface HeaderDao {
@Query("SELECT * FROM headers ORDER BY siteId ASC")
fun all(): List<Header>
@Query("SELECT * FROM headers WHERE siteId = :siteId")
fun forSite(siteId: Long): List<Header>
@Insert(onConflict = FAIL)
fun insert(headers: Header): Long
@Insert(onConflict = FAIL)
fun insert(headers: List<Header>): List<Long>
@Update(onConflict = FAIL)
fun update(header: Header): Int
@Delete
fun delete(headers: List<Header>): Int
}

View file

@ -15,7 +15,7 @@
*/ */
@file:Suppress("unused") @file:Suppress("unused")
package com.afollestad.nocknock.data.model package com.afollestad.nocknock.data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@ -57,14 +57,6 @@ data class RetryPolicy(
return -1 return -1
} }
val timesPerMinute = count.toFloat() / minutes.toFloat() val timesPerMinute = count.toFloat() / minutes.toFloat()
return MINUTE / timesPerMinute.toSafeInt() return MINUTE / timesPerMinute.toInt()
}
private fun Float.toSafeInt(): Int {
val intValue = toInt()
if (intValue == 0) {
return 1
}
return intValue
} }
} }

View file

@ -21,7 +21,6 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy.FAIL import androidx.room.OnConflictStrategy.FAIL
import androidx.room.Query import androidx.room.Query
import androidx.room.Update import androidx.room.Update
import com.afollestad.nocknock.data.model.RetryPolicy
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
@Dao @Dao

View file

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

View file

@ -18,6 +18,7 @@ package com.afollestad.nocknock.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.afollestad.nocknock.data.RetryPolicy
import com.afollestad.nocknock.data.model.Status.WAITING import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.utilities.ext.timeString import com.afollestad.nocknock.utilities.ext.timeString
import com.afollestad.nocknock.utilities.providers.CanNotifyModel import com.afollestad.nocknock.utilities.providers.CanNotifyModel
@ -31,21 +32,17 @@ data class Site(
@PrimaryKey(autoGenerate = true) var id: Long = 0, @PrimaryKey(autoGenerate = true) var id: Long = 0,
/** The site's user-given name. */ /** The site's user-given name. */
var name: String, var name: String,
/** The URL at which validation attempts are made to. */ /** The URl at which validation attempts are made to. */
var url: String, var url: String,
/** Comma separated tags for this site. */
var tags: String,
/** Settings for the site. */ /** Settings for the site. */
@Ignore var settings: SiteSettings?, @Ignore var settings: SiteSettings?,
/** The last validation attempt result for the site, if any. */ /** The last validation attempt result for the site, if any. */
@Ignore var lastResult: ValidationResult?, @Ignore var lastResult: ValidationResult?,
/** The site's retry policy, if any. */ /** The site's retry policy, if any. */
@Ignore var retryPolicy: RetryPolicy?, @Ignore var retryPolicy: RetryPolicy?
/** Request headers sent with this site's validation attempts. */
@Ignore var headers: List<Header>
) : CanNotifyModel { ) : CanNotifyModel {
constructor() : this(0, "", "", "", null, null, null, emptyList()) constructor() : this(0, "", "", null, null, null)
override fun notifyId(): Int = id.toInt() override fun notifyId(): Int = id.toInt()
@ -53,8 +50,6 @@ data class Site(
override fun notifyTag(): String = url override fun notifyTag(): String = url
override fun notifyDescription() = lastResult?.reason
fun intervalText(): String { fun intervalText(): String {
requireNotNull(settings) { "Settings not queried." } requireNotNull(settings) { "Settings not queried." }
val lastCheck = lastResult?.timestampMs ?: -1 val lastCheck = lastResult?.timestampMs ?: -1

View file

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

View file

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

View file

@ -15,18 +15,14 @@
*/ */
package com.afollestad.nocknock.engine package com.afollestad.nocknock.engine
import com.afollestad.nocknock.engine.ssl.RealSslManager import com.afollestad.nocknock.engine.validation.RealValidationManager
import com.afollestad.nocknock.engine.ssl.SslManager import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.RealValidationExecutor
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import org.koin.dsl.module.module import org.koin.dsl.module.module
/** @author Aidan Follestad (@afollestad) */ /** @author Aidan Follestad (@afollestad) */
val engineModule = module { val engineModule = module {
single { single {
RealValidationExecutor(get(), get(), get(), get(), get(), get(), get()) RealValidationManager(get(), get(), get(), get(), get(), get())
} bind ValidationExecutor::class } bind ValidationManager::class
factory { RealSslManager(get()) } bind SslManager::class
} }

View file

@ -1,98 +0,0 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.engine.ssl
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.annotation.CheckResult
import com.afollestad.nocknock.utilities.ext.toUri
import okhttp3.OkHttpClient
import java.io.BufferedInputStream
import java.io.FileInputStream
import java.security.KeyStore
import java.security.cert.CertificateFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */
interface SslManager {
@CheckResult fun clientForCertificate(
certUri: String,
siteUri: String,
client: OkHttpClient
): OkHttpClient
}
/** @author Aidan Follestad (@afollestad) **/
class RealSslManager(
private val app: Application
) : SslManager {
override fun clientForCertificate(
certUri: String,
siteUri: String,
client: OkHttpClient
): OkHttpClient {
val parsedCertUri = certUri.toUri()
val parsedSiteUri = siteUri.toUri()
val siteHost = parsedSiteUri.host ?: ""
log("Loading certificate $certUri for host $siteHost")
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null)
val certInputStream = app.openUri(parsedCertUri)
val bis = BufferedInputStream(certInputStream)
val certificateFactory = CertificateFactory.getInstance("X.509")
while (bis.available() > 0) {
val cert = certificateFactory.generateCertificate(bis)
keyStore.setCertificateEntry(siteHost, cert)
}
val trustManagerFactory =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(keyStore)
val trustManagers = trustManagerFactory.trustManagers
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustManagers, null)
val trustManager = trustManagers.first() as X509TrustManager
log("Loaded successfully!")
return client.newBuilder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.hostnameVerifier { hostname, _ ->
log("Verifying hostname $hostname")
hostname == siteHost
}
.build()
}
}
private fun Context.openUri(uri: Uri) = when (uri.scheme) {
"content" -> {
contentResolver.openInputStream(uri) ?: throw IllegalStateException(
"Unable to open input stream to $uri"
)
}
"file" -> FileInputStream(uri.path)
else -> FileInputStream(uri.toString())
}

View file

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

View file

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

View file

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

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