Compare commits
69 commits
Author | SHA1 | Date | |
---|---|---|---|
|
23ba4a69cd | ||
|
dd9aec1dff | ||
|
406af590aa | ||
|
550f8c59be | ||
|
10d7fe33f9 | ||
|
35eda8f057 | ||
|
a0fd44ae7a | ||
|
351f718df8 | ||
|
e2f7db22d1 | ||
|
82c1a17c68 | ||
|
a6670e2bea | ||
|
5fc1569099 | ||
|
0770db5df5 | ||
|
97a0eda92c | ||
|
1ccb89bfc3 | ||
|
9ea9c78099 | ||
|
997c797598 | ||
|
b26543d244 | ||
|
8c3654c4ac | ||
|
df2652860e | ||
|
4da8cb5f11 | ||
|
334e9e823c | ||
|
6d382b93a5 | ||
|
ef18464728 | ||
|
872e99d80d | ||
|
7f507792a8 | ||
|
68b6944542 | ||
|
e39093b526 | ||
|
9514a5ec83 | ||
|
3e5b1d4d8e | ||
|
de59bf9ec1 | ||
|
0fbd27b54b | ||
|
33388bd5c2 | ||
|
75297c7ff5 | ||
|
c6fca52fe4 | ||
|
b3f8a43f71 | ||
|
7dc4ee7fb1 | ||
|
859dcb53ca | ||
|
f86ccbbe0c | ||
|
571e7ebff3 | ||
|
77f939b095 | ||
|
8f16ff2d33 | ||
|
4f5fec758e | ||
|
b369f9dfd3 | ||
|
38c8c92c1c | ||
|
6ae85ea061 | ||
|
34329f3a9f | ||
|
6bb131fb23 | ||
|
8535a6fe8b | ||
|
cd1651672f | ||
|
26d6d9abf8 | ||
|
909e5420ad | ||
|
55ea6674e6 | ||
|
2221c45789 | ||
|
deae0f0dc2 | ||
|
f207ed5f78 | ||
|
cbac2796aa | ||
|
e3820fd7d3 | ||
|
8dc2112e2d | ||
|
74f7aa8aa2 | ||
|
646bc25232 | ||
|
26ab76b363 | ||
|
56030af0f0 | ||
|
7f8db7b7d5 | ||
|
d293a83240 | ||
|
002149cd3f | ||
|
2756fc9fc7 | ||
|
67aa54ac22 | ||
|
2fe6f171ba |
|
@ -1,3 +0,0 @@
|
|||
[*.kt]
|
||||
indent_size = 2
|
||||
continuation_indent_size=4
|
28
.github/ISSUE_TEMPLATE.md
vendored
|
@ -1,28 +0,0 @@
|
|||
(`[x]` becomes a filled in checkbox, `[ ]` is an empty one)
|
||||
|
||||
- [ ] I have verified there are [no duplicate active or recent bugs, questions, or requests](https://github.com/afollestad/nock-nock/issues?q=is%3Aissue+is%3Aclosed)
|
||||
- [ ] I have given my issue a non-generic title.
|
||||
|
||||
---
|
||||
|
||||
If this is a improvement or feature request, you can remove everything below.
|
||||
Also, please consider making a pull request if you are capable of contributing.
|
||||
|
||||
###### Include the following:
|
||||
|
||||
- Nock Nock version: `0.x.x`
|
||||
- Affected device: Google Pixel 3 XL with Android 9.0
|
||||
|
||||
---
|
||||
|
||||
###### Reproduction Steps
|
||||
|
||||
1.
|
||||
|
||||
---
|
||||
|
||||
###### Expected Result
|
||||
|
||||
---
|
||||
|
||||
###### Actual Result
|
28
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Something is crashing or not working as intended
|
||||
|
||||
---
|
||||
|
||||
*Please consider making a Pull Request if you are capable of doing so.*
|
||||
|
||||
**App Version:**
|
||||
|
||||
x.x.x
|
||||
|
||||
**Affected Device(s):**
|
||||
|
||||
Google Pixel 3 XL with Android 9.0
|
||||
|
||||
**Describe the Bug:**
|
||||
|
||||
A clear description of what is the bug is.
|
||||
|
||||
**To Reproduce:**
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected Behavior:**
|
||||
|
||||
A clear description of what you expected to happen.
|
15
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
|
||||
---
|
||||
|
||||
*Please consider making a Pull Request if you are capable of doing so.*
|
||||
|
||||
**Description what you'd like to happen:**
|
||||
|
||||
A clear description if the feature or behavior you'd like implemented.
|
||||
|
||||
**Describe alternatives you've considered:**
|
||||
|
||||
A clear description of any alternative solutions you've considered.
|
|
@ -1,9 +1,8 @@
|
|||
|
||||
### Guidelines
|
||||
|
||||
1. You must run the `spotlessApply` task before commiting, either through Android Studio or with `./gradlew spotlessApply`.
|
||||
1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`.
|
||||
2. A PR should be focused and contained. If you are changing multiple unrelated things, they should be in separate PRs.
|
||||
3. A PR should fix a bug or solve a problem - something that only you would use is not necessarily something that should be published.
|
||||
4. Give your PR a detailed title and description - look over your code one last time before actually creating the PR. Give it a self-review.
|
||||
|
||||
**If you do not follow the guidelines, your PR will be rejected.**
|
||||
**If you do not follow the guidelines, your PR will be rejected.**
|
4
.gitignore
vendored
|
@ -180,4 +180,6 @@ gradle-app.setting
|
|||
.gradletasknamecache
|
||||
|
||||
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
|
||||
# gradle/wrapper/gradle-wrapper.properties
|
||||
# gradle/wrapper/gradle-wrapper.properties
|
||||
|
||||
app/google-services.json
|
37
.idea/misc.xml
generated
|
@ -5,7 +5,42 @@
|
|||
<configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" />
|
||||
</configurations>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
||||
<component name="NullableNotNullManager">
|
||||
<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" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
|
19
.travis.yml
|
@ -1,19 +0,0 @@
|
|||
language: android
|
||||
jdk: oraclejdk8
|
||||
before_script:
|
||||
- echo no | android create avd --force -n test -t android-22 --abi armeabi-v7a
|
||||
- emulator -avd test -no-audio -no-window &
|
||||
- android-wait-for-emulator
|
||||
- adb shell input keyevent 82 &
|
||||
android:
|
||||
components:
|
||||
- tools
|
||||
- platform-tools
|
||||
- build-tools-28.0.3
|
||||
- android-28
|
||||
- extra-android-support
|
||||
- extra-android-m2repository
|
||||
- extra-google-m2repository
|
||||
|
||||
licenses:
|
||||
- '.+'
|
|
@ -1,9 +1,8 @@
|
|||
## Nock Nock
|
||||
|
||||
[](https://travis-ci.org/afollestad/nock-nock)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
|
||||

|
||||

|
||||
|
||||
Nock Nock is a simple app which allows you to monitor your websites for maximum uptime.
|
||||
|
||||
|
|
|
@ -4,18 +4,6 @@ apply plugin: 'kotlin-android'
|
|||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
apply plugin: 'io.fabric'
|
||||
|
||||
def getFabricApiKey() {
|
||||
def propsFile = project.rootProject.file('local.properties')
|
||||
if (!propsFile.exists()) {
|
||||
return ""
|
||||
}
|
||||
Properties properties = new Properties()
|
||||
properties.load(propsFile.newDataInputStream())
|
||||
return properties.getProperty("fabric.apikey") ?: ""
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion versions.compileSdk
|
||||
buildToolsVersion versions.buildTools
|
||||
|
@ -26,16 +14,15 @@ android {
|
|||
targetSdkVersion versions.compileSdk
|
||||
versionCode versions.publishVersionCode
|
||||
versionName versions.publishVersion
|
||||
manifestPlaceholders = [fabricKey:getFabricApiKey()]
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField "String", "FABRIC_API_KEY", "\"\""
|
||||
}
|
||||
release {
|
||||
buildConfigField "String", "FABRIC_API_KEY", "\"${getFabricApiKey()}\""
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility 1.8
|
||||
targetCompatibility 1.8
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
exclude 'META-INF/atomicfu.kotlin_module'
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -51,6 +38,7 @@ dependencies {
|
|||
implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView
|
||||
implementation 'com.google.android.material:material:' + versions.googleMaterial
|
||||
implementation 'androidx.browser:browser:' + versions.androidxBrowser
|
||||
implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore
|
||||
|
||||
// Lifecycle
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle
|
||||
|
@ -84,4 +72,8 @@ dependencies {
|
|||
androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner
|
||||
}
|
||||
|
||||
apply from: '../spotless.gradle'
|
||||
apply from: '../spotless.gradle'
|
||||
apply from: '../mock/mock.gradle'
|
||||
|
||||
apply plugin: "io.fabric"
|
||||
apply plugin: 'com.google.gms.google-services'
|
|
@ -50,9 +50,6 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data
|
||||
android:name="io.fabric.ApiKey"
|
||||
android:value="${fabricKey}"/>
|
||||
<meta-data
|
||||
android:name="preloaded_fonts"
|
||||
android:resource="@array/preloaded_fonts"/>
|
||||
|
|
|
@ -20,12 +20,12 @@ import android.app.Application
|
|||
import android.app.Application.ActivityLifecycleCallbacks
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||
import androidx.core.text.HtmlCompat.fromHtml
|
||||
import com.afollestad.materialdialogs.utils.MDUtil.resolveColor
|
||||
import com.afollestad.nocknock.utilities.ext.toUri
|
||||
import com.afollestad.nocknock.utilities.ui.toast
|
||||
|
||||
typealias ActivityLifeChange = (activity: Activity, resumed: Boolean) -> Unit
|
||||
|
@ -57,8 +57,6 @@ fun Application.onActivityLifeChange(cb: ActivityLifeChange) {
|
|||
|
||||
fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY)
|
||||
|
||||
fun String.toUri() = Uri.parse(this)!!
|
||||
|
||||
fun Activity.viewUrl(url: String) {
|
||||
val customTabsIntent = CustomTabsIntent.Builder()
|
||||
.apply {
|
||||
|
|
|
@ -47,10 +47,8 @@ class NockNockApp : Application() {
|
|||
Timber.plant(DebugTree())
|
||||
}
|
||||
|
||||
if (BuildConfig.FABRIC_API_KEY.isNotEmpty()) {
|
||||
Timber.plant(FabricTree())
|
||||
Fabric.with(this, Crashlytics())
|
||||
}
|
||||
Timber.plant(FabricTree())
|
||||
Fabric.with(this, Crashlytics())
|
||||
|
||||
val modules = listOf(
|
||||
prefModule,
|
||||
|
|
115
app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt
Normal file
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Designed and developed by Aidan Follestad (@afollestad)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.afollestad.nocknock.adapter
|
||||
|
||||
import android.graphics.Color.WHITE
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.afollestad.nocknock.R
|
||||
import com.afollestad.nocknock.adapter.TagAdapter.TagViewHolder
|
||||
import kotlinx.android.synthetic.main.list_item_tag.view.chip
|
||||
|
||||
typealias TagsListener = (tags: List<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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ import android.os.Bundle
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.nocknock.BuildConfig
|
||||
import com.afollestad.nocknock.R
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
|
@ -34,8 +35,9 @@ class AboutDialog : DialogFragment() {
|
|||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return MaterialDialog(activity!!)
|
||||
.title(R.string.about)
|
||||
val context = activity ?: throw IllegalStateException("Oh no!")
|
||||
return MaterialDialog(context)
|
||||
.title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME))
|
||||
.positiveButton(R.string.dismiss)
|
||||
.message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
|
||||
}
|
||||
|
|
|
@ -23,6 +23,9 @@ import android.content.Context.NOTIFICATION_SERVICE
|
|||
import androidx.room.Room.databaseBuilder
|
||||
import com.afollestad.nocknock.data.AppDatabase
|
||||
import com.afollestad.nocknock.data.Database1to2Migration
|
||||
import com.afollestad.nocknock.data.Database2to3Migration
|
||||
import com.afollestad.nocknock.data.Database3to4Migration
|
||||
import com.afollestad.nocknock.data.Database4to5Migration
|
||||
import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS
|
||||
import com.afollestad.nocknock.ui.main.MainActivity
|
||||
import com.afollestad.nocknock.utilities.ext.systemService
|
||||
|
@ -38,7 +41,12 @@ val mainModule = module {
|
|||
|
||||
single {
|
||||
databaseBuilder(get(), AppDatabase::class.java, "NockNock.db")
|
||||
.addMigrations(Database1to2Migration())
|
||||
.addMigrations(
|
||||
Database1to2Migration(),
|
||||
Database2to3Migration(),
|
||||
Database3to4Migration(),
|
||||
Database4to5Migration()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
|
@ -15,10 +15,15 @@
|
|||
*/
|
||||
package com.afollestad.nocknock.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.afollestad.nocknock.R
|
||||
import com.afollestad.nocknock.koin.PREF_DARK_MODE
|
||||
import com.afollestad.nocknock.ui.NightMode.DISABLED
|
||||
import com.afollestad.nocknock.ui.NightMode.ENABLED
|
||||
import com.afollestad.nocknock.ui.NightMode.UNKNOWN
|
||||
import com.afollestad.nocknock.utilities.rx.attachLifecycle
|
||||
import com.afollestad.rxkprefs.Pref
|
||||
import org.koin.android.ext.android.inject
|
||||
|
@ -35,16 +40,35 @@ abstract class DarkModeSwitchActivity : AppCompatActivity() {
|
|||
setTheme(themeRes())
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
darkModePref.observe()
|
||||
.filter { it != isDarkModeEnabled }
|
||||
.subscribe {
|
||||
log("Theme changed, recreating Activity.")
|
||||
recreate()
|
||||
}
|
||||
.attachLifecycle(this)
|
||||
if (getCurrentNightMode() == UNKNOWN) {
|
||||
darkModePref.observe()
|
||||
.filter { it != isDarkModeEnabled }
|
||||
.subscribe {
|
||||
log("Theme changed, recreating Activity.")
|
||||
recreate()
|
||||
}
|
||||
.attachLifecycle(this)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun isDarkMode() = darkModePref.get()
|
||||
protected fun getCurrentNightMode(): NightMode {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
return UNKNOWN
|
||||
}
|
||||
return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
|
||||
Configuration.UI_MODE_NIGHT_YES -> return ENABLED
|
||||
Configuration.UI_MODE_NIGHT_NO -> return DISABLED
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
protected fun isDarkMode(): Boolean {
|
||||
return when (getCurrentNightMode()) {
|
||||
ENABLED -> true
|
||||
DISABLED -> false
|
||||
else -> darkModePref.get()
|
||||
}
|
||||
}
|
||||
|
||||
protected fun toggleDarkMode() = setDarkMode(!isDarkMode())
|
||||
|
||||
|
|
26
app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt
Normal file
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Designed and developed by Aidan Follestad (@afollestad)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.afollestad.nocknock.ui
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
enum class NightMode {
|
||||
/** Night mode is on at the system level. */
|
||||
ENABLED,
|
||||
/** Night mode is off at the system level. */
|
||||
DISABLED,
|
||||
/** We don't know about night mode, fallback to custom impl. */
|
||||
UNKNOWN
|
||||
}
|
|
@ -16,18 +16,32 @@
|
|||
package com.afollestad.nocknock.ui.addsite
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ACTION_OPEN_DOCUMENT
|
||||
import android.content.Intent.CATEGORY_OPENABLE
|
||||
import android.os.Bundle
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.lifecycle.Observer
|
||||
import com.afollestad.nocknock.R
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.ValidationMode
|
||||
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
|
||||
import com.afollestad.nocknock.ui.viewsite.KEY_SITE
|
||||
import com.afollestad.nocknock.utilities.ext.onTextChanged
|
||||
import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection
|
||||
import com.afollestad.nocknock.utilities.livedata.distinct
|
||||
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
|
||||
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
|
||||
import com.afollestad.nocknock.viewcomponents.ext.onScroll
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
|
||||
import com.afollestad.vvalidator.form
|
||||
import com.afollestad.vvalidator.form.Form
|
||||
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
|
||||
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
|
||||
import kotlinx.android.synthetic.main.activity_addsite.headersLayout
|
||||
import kotlinx.android.synthetic.main.activity_addsite.inputName
|
||||
import kotlinx.android.synthetic.main.activity_addsite.inputTags
|
||||
import kotlinx.android.synthetic.main.activity_addsite.inputUrl
|
||||
import kotlinx.android.synthetic.main.activity_addsite.loadingProgress
|
||||
import kotlinx.android.synthetic.main.activity_addsite.responseTimeoutInput
|
||||
|
@ -35,44 +49,54 @@ import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode
|
|||
import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
|
||||
import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout
|
||||
import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout
|
||||
import kotlinx.android.synthetic.main.activity_addsite.scrollView
|
||||
import kotlinx.android.synthetic.main.activity_addsite.sslCertificateBrowse
|
||||
import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput
|
||||
import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
|
||||
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
|
||||
import kotlinx.android.synthetic.main.include_app_bar.toolbar
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
|
||||
import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
class AddSiteActivity : DarkModeSwitchActivity() {
|
||||
companion object {
|
||||
private const val SELECT_CERT_FILE_RQ = 23
|
||||
}
|
||||
|
||||
private val viewModel by viewModel<AddSiteViewModel>()
|
||||
private lateinit var validationForm: Form
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_addsite)
|
||||
setupUi()
|
||||
setupValidation()
|
||||
|
||||
lifecycle.addObserver(viewModel)
|
||||
|
||||
// Populate view model with initial data
|
||||
val model = intent.getSerializableExtra(KEY_SITE) as? Site
|
||||
model?.let { viewModel.prePopulateFromModel(model) }
|
||||
|
||||
// Loading
|
||||
loadingProgress.observe(this, viewModel.onIsLoading())
|
||||
|
||||
// Name
|
||||
inputName.attachLiveData(this, viewModel.name)
|
||||
viewModel.onNameError()
|
||||
.toViewError(this, inputName)
|
||||
|
||||
// Tags
|
||||
inputTags.attachLiveData(this, viewModel.tags)
|
||||
|
||||
// Url
|
||||
inputUrl.attachLiveData(this, viewModel.url)
|
||||
viewModel.onUrlError()
|
||||
.toViewError(this, inputUrl)
|
||||
viewModel.onUrlWarningVisibility()
|
||||
.toViewVisibility(this, textUrlWarning)
|
||||
|
||||
// Timeout
|
||||
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
|
||||
viewModel.onTimeoutError()
|
||||
.toViewError(this, responseTimeoutInput)
|
||||
|
||||
// Validation mode
|
||||
responseValidationMode.attachLiveData(
|
||||
|
@ -81,8 +105,6 @@ class AddSiteActivity : DarkModeSwitchActivity() {
|
|||
outTransformer = { ValidationMode.fromIndex(it) },
|
||||
inTransformer = { it.toIndex() }
|
||||
)
|
||||
viewModel.onValidationSearchTermError()
|
||||
.toViewError(this, responseValidationSearchTerm)
|
||||
viewModel.onValidationModeDescription()
|
||||
.toViewText(this, validationModeDescription)
|
||||
|
||||
|
@ -95,30 +117,19 @@ class AddSiteActivity : DarkModeSwitchActivity() {
|
|||
viewModel.onValidationSearchTermVisibility()
|
||||
.toViewVisibility(this, responseValidationSearchTerm)
|
||||
|
||||
// Validation script
|
||||
scriptInputLayout.attach(
|
||||
codeData = viewModel.validationScript,
|
||||
errorData = viewModel.onValidationScriptError(),
|
||||
visibility = viewModel.onValidationScriptVisibility()
|
||||
)
|
||||
// SSL certificate
|
||||
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
|
||||
viewModel.certificateUri.distinct()
|
||||
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
|
||||
|
||||
// Check interval
|
||||
checkIntervalLayout.attach(
|
||||
valueData = viewModel.checkIntervalValue,
|
||||
multiplierData = viewModel.checkIntervalUnit,
|
||||
errorData = viewModel.onCheckIntervalError()
|
||||
)
|
||||
|
||||
// Retry Policy
|
||||
retryPolicyLayout.attach(
|
||||
timesData = viewModel.retryPolicyTimes,
|
||||
minutesData = viewModel.retryPolicyMinutes
|
||||
)
|
||||
// Headers
|
||||
headersLayout.attach(viewModel.headers)
|
||||
}
|
||||
|
||||
private fun setupUi() {
|
||||
toolbarTitle.setText(R.string.add_site)
|
||||
toolbar.run {
|
||||
inflateMenu(R.menu.menu_addsite)
|
||||
setNavigationIcon(R.drawable.ic_action_close)
|
||||
setNavigationOnClickListener { finish() }
|
||||
}
|
||||
|
@ -131,12 +142,94 @@ class AddSiteActivity : DarkModeSwitchActivity() {
|
|||
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
|
||||
responseValidationMode.adapter = validationOptionsAdapter
|
||||
|
||||
// Done button
|
||||
doneBtn.setOnClickListener {
|
||||
viewModel.commit {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
scrollView.onScroll {
|
||||
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
|
||||
appToolbar.dimenFloat(R.dimen.default_elevation)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
// SSL certificate
|
||||
sslCertificateBrowse.setOnClickListener {
|
||||
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
}
|
||||
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupValidation() {
|
||||
validationForm = form {
|
||||
input(inputName, name = "Name") {
|
||||
isNotEmpty().description(R.string.please_enter_name)
|
||||
}
|
||||
input(inputUrl, name = "URL") {
|
||||
isNotEmpty().description(R.string.please_enter_url)
|
||||
isUrl().description(R.string.please_enter_valid_url)
|
||||
}
|
||||
input(responseTimeoutInput, name = "Timeout", optional = true) {
|
||||
isNumber().greaterThan(0)
|
||||
.description(R.string.please_enter_networkTimeout)
|
||||
}
|
||||
input(responseValidationSearchTerm, name = "Search term") {
|
||||
conditional(responseValidationSearchTerm.isVisibleCondition()) {
|
||||
isNotEmpty().description(R.string.please_enter_search_term)
|
||||
}
|
||||
}
|
||||
input(sslCertificateInput, name = "Certificate Path", optional = true) {
|
||||
isUri().hasScheme("file", "content")
|
||||
.that { it.host != null }
|
||||
.description(R.string.please_enter_validCertUri)
|
||||
}
|
||||
submitWith(toolbar.menu, R.id.commit) {
|
||||
viewModel.commit {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validation script
|
||||
scriptInputLayout.attach(
|
||||
codeData = viewModel.validationScript,
|
||||
visibility = viewModel.onValidationScriptVisibility(),
|
||||
form = validationForm
|
||||
)
|
||||
|
||||
// Check interval
|
||||
checkIntervalLayout.attach(
|
||||
valueData = viewModel.checkIntervalValue,
|
||||
multiplierData = viewModel.checkIntervalUnit,
|
||||
form = validationForm
|
||||
)
|
||||
|
||||
// Retry Policy
|
||||
retryPolicyLayout.attach(
|
||||
timesData = viewModel.retryPolicyTimes,
|
||||
minutesData = viewModel.retryPolicyMinutes,
|
||||
form = validationForm
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
|
||||
appToolbar.dimenFloat(R.dimen.default_elevation)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
resultData: Intent?
|
||||
) {
|
||||
super.onActivityResult(requestCode, resultCode, resultData)
|
||||
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
|
||||
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,8 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import com.afollestad.nocknock.R
|
||||
import com.afollestad.nocknock.data.AppDatabase
|
||||
import com.afollestad.nocknock.data.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.SiteSettings
|
||||
import com.afollestad.nocknock.data.model.Status.WAITING
|
||||
|
@ -35,11 +36,10 @@ import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
|
|||
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
|
||||
import com.afollestad.nocknock.data.model.ValidationResult
|
||||
import com.afollestad.nocknock.data.putSite
|
||||
import com.afollestad.nocknock.engine.validation.ValidationManager
|
||||
import com.afollestad.nocknock.engine.validation.ValidationExecutor
|
||||
import com.afollestad.nocknock.ui.ScopedViewModel
|
||||
import com.afollestad.nocknock.utilities.ext.MINUTE
|
||||
import com.afollestad.nocknock.utilities.livedata.map
|
||||
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -49,13 +49,14 @@ import java.lang.System.currentTimeMillis
|
|||
/** @author Aidan Follestad (@afollestad) */
|
||||
class AddSiteViewModel(
|
||||
private val database: AppDatabase,
|
||||
private val validationManager: ValidationManager,
|
||||
private val validationManager: ValidationExecutor,
|
||||
mainDispatcher: CoroutineDispatcher,
|
||||
private val ioDispatcher: CoroutineDispatcher
|
||||
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
|
||||
|
||||
// Public properties
|
||||
val name = MutableLiveData<String>()
|
||||
val tags = MutableLiveData<String>()
|
||||
val url = MutableLiveData<String>()
|
||||
val timeout = MutableLiveData<Int>()
|
||||
val validationMode = MutableLiveData<ValidationMode>()
|
||||
|
@ -65,6 +66,8 @@ class AddSiteViewModel(
|
|||
val checkIntervalUnit = MutableLiveData<Long>()
|
||||
val retryPolicyTimes = MutableLiveData<Int>()
|
||||
val retryPolicyMinutes = MutableLiveData<Int>()
|
||||
val headers = MutableLiveData<List<Header>>()
|
||||
val certificateUri = MutableLiveData<String>()
|
||||
|
||||
@OnLifecycleEvent(ON_START)
|
||||
fun setDefaults() {
|
||||
|
@ -74,24 +77,14 @@ class AddSiteViewModel(
|
|||
checkIntervalUnit.value = MINUTE
|
||||
retryPolicyMinutes.value = 0
|
||||
retryPolicyMinutes.value = 0
|
||||
tags.value = ""
|
||||
headers.value = emptyList()
|
||||
}
|
||||
|
||||
// Private properties
|
||||
private val isLoading = MutableLiveData<Boolean>()
|
||||
private val nameError = MutableLiveData<Int?>()
|
||||
private val urlError = MutableLiveData<Int?>()
|
||||
private val timeoutError = MutableLiveData<Int?>()
|
||||
private val validationSearchTermError = MutableLiveData<Int?>()
|
||||
private val validationScriptError = MutableLiveData<Int?>()
|
||||
private val checkIntervalValueError = MutableLiveData<Int?>()
|
||||
|
||||
// Expose private properties or calculated properties
|
||||
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
|
||||
|
||||
@CheckResult fun onNameError(): LiveData<Int?> = nameError
|
||||
|
||||
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
|
||||
|
||||
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
|
||||
return url.map {
|
||||
val parsed = HttpUrl.parse(it)
|
||||
|
@ -99,8 +92,6 @@ class AddSiteViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
|
||||
|
||||
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
|
||||
return validationMode.map {
|
||||
when (it!!) {
|
||||
|
@ -111,17 +102,9 @@ class AddSiteViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError
|
||||
@CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
|
||||
|
||||
@CheckResult fun onValidationSearchTermVisibility() =
|
||||
validationMode.map { it == TERM_SEARCH }
|
||||
|
||||
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
|
||||
|
||||
@CheckResult fun onValidationScriptVisibility() =
|
||||
validationMode.map { it == JAVASCRIPT }
|
||||
|
||||
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
|
||||
@CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
|
||||
|
||||
// Actions
|
||||
fun commit(done: () -> Unit) {
|
||||
|
@ -132,7 +115,7 @@ class AddSiteViewModel(
|
|||
val storedModel = withContext(ioDispatcher) {
|
||||
database.putSite(newModel)
|
||||
}
|
||||
validationManager.scheduleCheck(
|
||||
validationManager.scheduleValidation(
|
||||
site = storedModel,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
|
@ -161,75 +144,16 @@ class AddSiteViewModel(
|
|||
}
|
||||
|
||||
private fun generateDbModel(): Site? {
|
||||
var errorCount = 0
|
||||
|
||||
// Validation name
|
||||
if (name.value.isNullOrEmpty()) {
|
||||
nameError.value = R.string.please_enter_name
|
||||
errorCount++
|
||||
} else {
|
||||
nameError.value = null
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
when {
|
||||
url.value.isNullOrEmpty() -> {
|
||||
urlError.value = R.string.please_enter_url
|
||||
errorCount++
|
||||
}
|
||||
HttpUrl.parse(url.value!!) == null -> {
|
||||
urlError.value = R.string.please_enter_valid_url
|
||||
errorCount++
|
||||
}
|
||||
else -> {
|
||||
urlError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Validate timeout
|
||||
if (timeout.value.isNullOrLessThan(1)) {
|
||||
timeoutError.value = R.string.please_enter_networkTimeout
|
||||
errorCount++
|
||||
} else {
|
||||
timeoutError.value = null
|
||||
}
|
||||
|
||||
// Validate check interval
|
||||
if (checkIntervalValue.value.isNullOrLessThan(1)) {
|
||||
checkIntervalValueError.value = R.string.please_enter_check_interval
|
||||
errorCount++
|
||||
} else {
|
||||
checkIntervalValueError.value = null
|
||||
}
|
||||
|
||||
// Validate arguments
|
||||
if (validationMode.value == TERM_SEARCH &&
|
||||
validationSearchTerm.value.isNullOrEmpty()
|
||||
) {
|
||||
errorCount++
|
||||
validationSearchTermError.value = R.string.please_enter_search_term
|
||||
validationScriptError.value = null
|
||||
} else if (validationMode.value == JAVASCRIPT &&
|
||||
validationScript.value.isNullOrEmpty()
|
||||
) {
|
||||
errorCount++
|
||||
validationSearchTermError.value = null
|
||||
validationScriptError.value = R.string.please_enter_javaScript
|
||||
} else {
|
||||
validationSearchTermError.value = null
|
||||
validationScriptError.value = null
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
return null
|
||||
}
|
||||
val timeout = timeout.value ?: 10_000
|
||||
val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
|
||||
|
||||
val newSettings = SiteSettings(
|
||||
validationIntervalMs = getCheckIntervalMs(),
|
||||
validationMode = validationMode.value!!,
|
||||
validationArgs = getValidationArgs(),
|
||||
networkTimeout = timeout.value!!,
|
||||
disabled = false
|
||||
networkTimeout = timeout,
|
||||
disabled = false,
|
||||
certificate = certificateUri.value?.toString()
|
||||
)
|
||||
|
||||
val newLastResult = ValidationResult(
|
||||
|
@ -241,7 +165,10 @@ class AddSiteViewModel(
|
|||
val retryPolicyTimes = retryPolicyTimes.value ?: 0
|
||||
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
|
||||
val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
|
||||
RetryPolicy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
|
||||
RetryPolicy(
|
||||
count = retryPolicyTimes,
|
||||
minutes = retryPolicyMinutes
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
@ -250,9 +177,11 @@ class AddSiteViewModel(
|
|||
id = 0,
|
||||
name = name.value!!.trim(),
|
||||
url = url.value!!.trim(),
|
||||
tags = cleanedTags,
|
||||
settings = newSettings,
|
||||
lastResult = newLastResult,
|
||||
retryPolicy = newRetryPolicy
|
||||
retryPolicy = newRetryPolicy,
|
||||
headers = headers.value ?: emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Designed and developed by Aidan Follestad (@afollestad)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.afollestad.nocknock.ui.addsite
|
||||
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
|
||||
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
|
||||
import com.afollestad.nocknock.utilities.ext.DAY
|
||||
import com.afollestad.nocknock.utilities.ext.HOUR
|
||||
import com.afollestad.nocknock.utilities.ext.MINUTE
|
||||
import com.afollestad.nocknock.utilities.ext.WEEK
|
||||
import kotlin.math.ceil
|
||||
|
||||
fun AddSiteViewModel.prePopulateFromModel(site: Site) {
|
||||
val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!")
|
||||
|
||||
name.value = site.name
|
||||
tags.value = site.tags
|
||||
url.value = site.url
|
||||
timeout.value = settings.networkTimeout
|
||||
|
||||
validationMode.value = settings.validationMode
|
||||
when (settings.validationMode) {
|
||||
TERM_SEARCH -> {
|
||||
validationSearchTerm.value = settings.validationArgs
|
||||
validationScript.value = null
|
||||
}
|
||||
JAVASCRIPT -> {
|
||||
validationSearchTerm.value = null
|
||||
validationScript.value = settings.validationArgs
|
||||
}
|
||||
else -> {
|
||||
validationSearchTerm.value = null
|
||||
validationScript.value = null
|
||||
}
|
||||
}
|
||||
|
||||
setCheckInterval(settings.validationIntervalMs)
|
||||
setRetryPolicy(site.retryPolicy)
|
||||
headers.value = site.headers
|
||||
}
|
||||
|
||||
private fun AddSiteViewModel.setCheckInterval(interval: Long) {
|
||||
when {
|
||||
interval >= WEEK -> {
|
||||
checkIntervalValue.value =
|
||||
getIntervalFromUnit(interval, WEEK)
|
||||
checkIntervalUnit.value = WEEK
|
||||
}
|
||||
interval >= DAY -> {
|
||||
checkIntervalValue.value =
|
||||
getIntervalFromUnit(interval, DAY)
|
||||
checkIntervalUnit.value = DAY
|
||||
}
|
||||
interval >= HOUR -> {
|
||||
checkIntervalValue.value =
|
||||
getIntervalFromUnit(interval, HOUR)
|
||||
checkIntervalUnit.value = HOUR
|
||||
}
|
||||
interval >= MINUTE -> {
|
||||
checkIntervalValue.value =
|
||||
getIntervalFromUnit(interval, MINUTE)
|
||||
checkIntervalUnit.value = MINUTE
|
||||
}
|
||||
else -> {
|
||||
checkIntervalValue.value = 0
|
||||
checkIntervalUnit.value = MINUTE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AddSiteViewModel.setRetryPolicy(policy: RetryPolicy?) {
|
||||
if (policy == null) return
|
||||
retryPolicyTimes.value = policy.count
|
||||
retryPolicyMinutes.value = policy.minutes
|
||||
}
|
||||
|
||||
private fun getIntervalFromUnit(
|
||||
millis: Long,
|
||||
unit: Long
|
||||
): Int {
|
||||
val intervalFloat = millis.toFloat()
|
||||
val byFloat = unit.toFloat()
|
||||
return ceil(intervalFloat / byFloat).toInt()
|
||||
}
|
|
@ -21,20 +21,19 @@ import androidx.lifecycle.Observer
|
|||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.afollestad.materialdialogs.list.listItems
|
||||
import com.afollestad.materialdialogs.list.listItemsSingleChoice
|
||||
import com.afollestad.nocknock.R
|
||||
import com.afollestad.nocknock.adapter.SiteAdapter
|
||||
import com.afollestad.nocknock.adapter.TagAdapter
|
||||
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.dialogs.AboutDialog
|
||||
import com.afollestad.nocknock.notifications.NockNotificationManager
|
||||
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
|
||||
import com.afollestad.nocknock.ui.NightMode.UNKNOWN
|
||||
import com.afollestad.nocknock.utilities.providers.IntentProvider
|
||||
import com.afollestad.nocknock.utilities.ui.toast
|
||||
import com.afollestad.nocknock.viewUrl
|
||||
import com.afollestad.nocknock.viewUrlWithApp
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
|
||||
import kotlinx.android.synthetic.main.activity_main.fab
|
||||
import kotlinx.android.synthetic.main.activity_main.list
|
||||
|
@ -43,6 +42,7 @@ import kotlinx.android.synthetic.main.include_app_bar.toolbar
|
|||
import kotlinx.android.synthetic.main.include_empty_view.emptyText
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import kotlinx.android.synthetic.main.activity_main.tags_list as tagsList
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
class MainActivity : DarkModeSwitchActivity() {
|
||||
|
@ -53,6 +53,7 @@ class MainActivity : DarkModeSwitchActivity() {
|
|||
internal val viewModel by viewModel<MainViewModel>()
|
||||
|
||||
private lateinit var siteAdapter: SiteAdapter
|
||||
private lateinit var tagAdapter: TagAdapter
|
||||
|
||||
private val statusUpdateReceiver by lazy {
|
||||
StatusUpdateIntentReceiver(application, intentProvider) {
|
||||
|
@ -76,6 +77,10 @@ class MainActivity : DarkModeSwitchActivity() {
|
|||
.observe(this, Observer { siteAdapter.set(it) })
|
||||
viewModel.onEmptyTextVisibility()
|
||||
.toViewVisibility(this, emptyText)
|
||||
viewModel.onTags()
|
||||
.observe(this, Observer { tagAdapter.set(it) })
|
||||
viewModel.onTagsListVisibility()
|
||||
.toViewVisibility(this, tagsList)
|
||||
loadingProgress.observe(this, viewModel.onIsLoading())
|
||||
|
||||
processIntent(intent)
|
||||
|
@ -85,24 +90,35 @@ class MainActivity : DarkModeSwitchActivity() {
|
|||
toolbar.run {
|
||||
inflateMenu(R.menu.menu_main)
|
||||
menu.findItem(R.id.dark_mode)
|
||||
.isChecked = isDarkMode()
|
||||
.apply {
|
||||
if (getCurrentNightMode() == UNKNOWN) {
|
||||
isChecked = isDarkMode()
|
||||
} else {
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.about -> AboutDialog.show(this@MainActivity)
|
||||
R.id.dark_mode -> toggleDarkMode()
|
||||
R.id.support_me -> supportMe()
|
||||
}
|
||||
return@setOnMenuItemClickListener true
|
||||
}
|
||||
}
|
||||
|
||||
siteAdapter = SiteAdapter(this::onSiteSelected)
|
||||
|
||||
list.run {
|
||||
layoutManager = LinearLayoutManager(this@MainActivity)
|
||||
adapter = siteAdapter
|
||||
addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL))
|
||||
}
|
||||
|
||||
tagAdapter = TagAdapter(viewModel::onTagSelection)
|
||||
tagsList.run {
|
||||
layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false)
|
||||
adapter = tagAdapter
|
||||
}
|
||||
|
||||
fab.setOnClickListener { addSite() }
|
||||
}
|
||||
|
||||
|
@ -121,7 +137,8 @@ class MainActivity : DarkModeSwitchActivity() {
|
|||
listItems(R.array.site_long_options) { _, i, _ ->
|
||||
when (i) {
|
||||
0 -> viewModel.refreshSite(model)
|
||||
1 -> maybeRemoveSite(model)
|
||||
1 -> addSiteForDuplication(model)
|
||||
2 -> maybeRemoveSite(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -129,20 +146,4 @@ class MainActivity : DarkModeSwitchActivity() {
|
|||
viewSite(model)
|
||||
}
|
||||
}
|
||||
|
||||
private fun supportMe() {
|
||||
MaterialDialog(this).show {
|
||||
title(R.string.support_me)
|
||||
message(R.string.support_me_message, html = true, lineHeightMultiplier = 1.4f)
|
||||
listItemsSingleChoice(R.array.donation_options) { _, index, _ ->
|
||||
when (index) {
|
||||
0 -> viewUrl("https://paypal.me/AidanFollestad")
|
||||
1 -> viewUrlWithApp("https://cash.me/\$afollestad", pkg = "com.squareup.cash")
|
||||
2 -> viewUrlWithApp("https://venmo.com/afollestad", pkg = "com.venmo")
|
||||
}
|
||||
toast(R.string.thank_you)
|
||||
}
|
||||
positiveButton(R.string.next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,10 +28,23 @@ import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.
|
|||
internal const val VIEW_SITE_RQ = 6923
|
||||
internal const val ADD_SITE_RQ = 6969
|
||||
|
||||
// ADD
|
||||
|
||||
internal fun MainActivity.addSite() {
|
||||
startActivityForResult(Intent(this, AddSiteActivity::class.java), ADD_SITE_RQ)
|
||||
startActivityForResult(intentToAdd(), ADD_SITE_RQ)
|
||||
}
|
||||
|
||||
internal fun MainActivity.addSiteForDuplication(site: Site) {
|
||||
startActivityForResult(intentToAdd(site), ADD_SITE_RQ)
|
||||
}
|
||||
|
||||
private fun MainActivity.intentToAdd(model: Site? = null) =
|
||||
Intent(this, AddSiteActivity::class.java).apply {
|
||||
model?.let { putExtra(KEY_SITE, it) }
|
||||
}
|
||||
|
||||
// VIEW
|
||||
|
||||
internal fun MainActivity.viewSite(model: Site) {
|
||||
startActivityForResult(intentToView(model), VIEW_SITE_RQ)
|
||||
}
|
||||
|
@ -41,6 +54,8 @@ private fun MainActivity.intentToView(model: Site) =
|
|||
putExtra(KEY_SITE, model)
|
||||
}
|
||||
|
||||
// MISC
|
||||
|
||||
internal fun MainActivity.maybeRemoveSite(model: Site) {
|
||||
MaterialDialog(this).show {
|
||||
title(R.string.remove_site)
|
||||
|
|
|
@ -25,7 +25,7 @@ import com.afollestad.nocknock.data.AppDatabase
|
|||
import com.afollestad.nocknock.data.allSites
|
||||
import com.afollestad.nocknock.data.deleteSite
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.engine.validation.ValidationManager
|
||||
import com.afollestad.nocknock.engine.validation.ValidationExecutor
|
||||
import com.afollestad.nocknock.notifications.NockNotificationManager
|
||||
import com.afollestad.nocknock.ui.ScopedViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
@ -36,7 +36,7 @@ import kotlinx.coroutines.withContext
|
|||
class MainViewModel(
|
||||
private val database: AppDatabase,
|
||||
private val notificationManager: NockNotificationManager,
|
||||
private val validationManager: ValidationManager,
|
||||
private val validationManager: ValidationExecutor,
|
||||
mainDispatcher: CoroutineDispatcher,
|
||||
private val ioDispatcher: CoroutineDispatcher
|
||||
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
|
||||
|
@ -44,6 +44,8 @@ class MainViewModel(
|
|||
private val sites = MutableLiveData<List<Site>>()
|
||||
private val isLoading = 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
|
||||
|
||||
|
@ -51,8 +53,14 @@ class MainViewModel(
|
|||
|
||||
@CheckResult fun onEmptyTextVisibility(): LiveData<Boolean> = emptyTextVisibility
|
||||
|
||||
@CheckResult fun onTags(): LiveData<List<String>> = tags
|
||||
|
||||
@CheckResult fun onTagsListVisibility(): LiveData<Boolean> = tagsListVisibility
|
||||
|
||||
@OnLifecycleEvent(ON_RESUME)
|
||||
fun onResume() = loadSites()
|
||||
fun onResume() = loadSites(emptyList())
|
||||
|
||||
fun onTagSelection(tags: List<String>) = loadSites(tags)
|
||||
|
||||
fun postSiteUpdate(model: Site) {
|
||||
val currentSites = sites.value ?: return
|
||||
|
@ -65,7 +73,7 @@ class MainViewModel(
|
|||
}
|
||||
|
||||
fun refreshSite(model: Site) {
|
||||
validationManager.scheduleCheck(
|
||||
validationManager.scheduleValidation(
|
||||
site = model,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
|
@ -73,7 +81,7 @@ class MainViewModel(
|
|||
}
|
||||
|
||||
fun removeSite(model: Site) {
|
||||
validationManager.cancelCheck(model)
|
||||
validationManager.cancelScheduledValidation(model)
|
||||
notificationManager.cancelStatusNotification(model)
|
||||
|
||||
scope.launch {
|
||||
|
@ -94,27 +102,56 @@ class MainViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
private fun loadSites() {
|
||||
private fun loadSites(forTags: List<String>) {
|
||||
scope.launch {
|
||||
notificationManager.cancelStatusNotifications()
|
||||
sites.value = listOf()
|
||||
emptyTextVisibility.value = false
|
||||
isLoading.value = true
|
||||
|
||||
val result = withContext(ioDispatcher) {
|
||||
val unfiltered = withContext(ioDispatcher) {
|
||||
database.allSites()
|
||||
}
|
||||
var result = unfiltered
|
||||
|
||||
if (forTags.isNotEmpty()) {
|
||||
result = result.filter { site ->
|
||||
val itemTags = site.tags.toLowerCase()
|
||||
.split(",")
|
||||
itemTags.any { tag -> forTags.contains(tag) }
|
||||
}
|
||||
}
|
||||
|
||||
sites.value = result
|
||||
ensureCheckJobs()
|
||||
isLoading.value = false
|
||||
emptyTextVisibility.value = result.isEmpty()
|
||||
|
||||
val tagsValues = pullOutTags(unfiltered)
|
||||
tags.value = tagsValues
|
||||
tagsListVisibility.value = tagsValues.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureCheckJobs() {
|
||||
withContext(ioDispatcher) {
|
||||
validationManager.ensureScheduledChecks()
|
||||
validationManager.ensureScheduledValidations()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pullOutTags(sites: List<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,8 @@ package com.afollestad.nocknock.ui.viewsite
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.Intent.ACTION_OPEN_DOCUMENT
|
||||
import android.content.Intent.CATEGORY_OPENABLE
|
||||
import android.os.Bundle
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.lifecycle.Observer
|
||||
|
@ -25,18 +27,23 @@ import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
|
|||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.ValidationMode
|
||||
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
|
||||
import com.afollestad.nocknock.utilities.ext.onTextChanged
|
||||
import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection
|
||||
import com.afollestad.nocknock.utilities.livedata.distinct
|
||||
import com.afollestad.nocknock.utilities.providers.IntentProvider
|
||||
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
|
||||
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
|
||||
import com.afollestad.nocknock.viewcomponents.ext.onScroll
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
|
||||
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
|
||||
import com.afollestad.vvalidator.form
|
||||
import com.afollestad.vvalidator.form.Form
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.headersLayout
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.inputName
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.inputTags
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.inputUrl
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput
|
||||
|
@ -45,6 +52,8 @@ import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearch
|
|||
import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.scrollView
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateBrowse
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateInput
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck
|
||||
import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning
|
||||
|
@ -52,12 +61,17 @@ import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescriptio
|
|||
import kotlinx.android.synthetic.main.include_app_bar.toolbar
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
|
||||
import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
class ViewSiteActivity : DarkModeSwitchActivity() {
|
||||
companion object {
|
||||
private const val SELECT_CERT_FILE_RQ = 23
|
||||
}
|
||||
|
||||
internal val viewModel by viewModel<ViewSiteViewModel>()
|
||||
private lateinit var validationForm: Form
|
||||
|
||||
private val intentProvider by inject<IntentProvider>()
|
||||
private val statusUpdateReceiver by lazy {
|
||||
|
@ -70,17 +84,18 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_viewsite)
|
||||
setupUi()
|
||||
|
||||
lifecycle.run {
|
||||
addObserver(viewModel)
|
||||
addObserver(statusUpdateReceiver)
|
||||
}
|
||||
|
||||
// Populate view model with initial data
|
||||
val model = intent.getSerializableExtra(KEY_SITE) as Site
|
||||
viewModel.setModel(model)
|
||||
|
||||
setupUi()
|
||||
setupValidation()
|
||||
lifecycle.run {
|
||||
addObserver(viewModel)
|
||||
addObserver(statusUpdateReceiver)
|
||||
}
|
||||
|
||||
// Loading
|
||||
loadingProgress.observe(this, viewModel.onIsLoading())
|
||||
|
||||
|
@ -92,20 +107,17 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
|
|||
|
||||
// Name
|
||||
inputName.attachLiveData(this, viewModel.name)
|
||||
viewModel.onNameError()
|
||||
.toViewError(this, inputName)
|
||||
|
||||
// Tags
|
||||
inputTags.attachLiveData(this, viewModel.tags)
|
||||
|
||||
// Url
|
||||
inputUrl.attachLiveData(this, viewModel.url)
|
||||
viewModel.onUrlError()
|
||||
.toViewError(this, inputUrl)
|
||||
viewModel.onUrlWarningVisibility()
|
||||
.toViewVisibility(this, textUrlWarning)
|
||||
|
||||
// Timeout
|
||||
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
|
||||
viewModel.onTimeoutError()
|
||||
.toViewError(this, responseTimeoutInput)
|
||||
|
||||
// Validation mode
|
||||
responseValidationMode.attachLiveData(
|
||||
|
@ -114,8 +126,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
|
|||
outTransformer = { ValidationMode.fromIndex(it) },
|
||||
inTransformer = { it.toIndex() }
|
||||
)
|
||||
viewModel.onValidationSearchTermError()
|
||||
.toViewError(this, responseValidationSearchTerm)
|
||||
viewModel.onValidationModeDescription()
|
||||
.toViewText(this, validationModeDescription)
|
||||
|
||||
|
@ -124,25 +134,13 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
|
|||
viewModel.onValidationSearchTermVisibility()
|
||||
.toViewVisibility(this, responseValidationSearchTerm)
|
||||
|
||||
// Validation script
|
||||
scriptInputLayout.attach(
|
||||
codeData = viewModel.validationScript,
|
||||
errorData = viewModel.onValidationScriptError(),
|
||||
visibility = viewModel.onValidationScriptVisibility()
|
||||
)
|
||||
// SSL certificate
|
||||
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
|
||||
viewModel.certificateUri.distinct()
|
||||
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
|
||||
|
||||
// Check interval
|
||||
checkIntervalLayout.attach(
|
||||
valueData = viewModel.checkIntervalValue,
|
||||
multiplierData = viewModel.checkIntervalUnit,
|
||||
errorData = viewModel.onCheckIntervalError()
|
||||
)
|
||||
|
||||
// Retry Policy
|
||||
retryPolicyLayout.attach(
|
||||
timesData = viewModel.retryPolicyTimes,
|
||||
minutesData = viewModel.retryPolicyMinutes
|
||||
)
|
||||
// Headers
|
||||
headersLayout.attach(viewModel.headers)
|
||||
|
||||
// Last/next check
|
||||
viewModel.onLastCheckResultText()
|
||||
|
@ -152,25 +150,30 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
|
|||
}
|
||||
|
||||
private fun setupUi() {
|
||||
toolbarTitle.setText(R.string.view_site)
|
||||
toolbarTitle.text = ""
|
||||
toolbar.run {
|
||||
setNavigationIcon(R.drawable.ic_action_close)
|
||||
setNavigationOnClickListener { finish() }
|
||||
inflateMenu(R.menu.menu_viewsite)
|
||||
|
||||
menu.findItem(R.id.refresh)
|
||||
.setActionView(R.layout.menu_item_refresh_icon)
|
||||
.apply {
|
||||
actionView.setOnClickListener { viewModel.checkNow() }
|
||||
}
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
maybeRemoveSite()
|
||||
when (it.itemId) {
|
||||
R.id.remove -> maybeRemoveSite()
|
||||
R.id.disableChecks -> maybeDisableChecks()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
scrollView.onScroll {
|
||||
toolbar.elevation = if (it > toolbar.height / 4) {
|
||||
toolbar.dimenFloat(R.dimen.default_elevation)
|
||||
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
|
||||
appToolbar.dimenFloat(R.dimen.default_elevation)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
@ -186,14 +189,95 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
|
|||
|
||||
// Disabled button
|
||||
viewModel.onDisableChecksVisibility()
|
||||
.toViewVisibility(this, disableChecksButton)
|
||||
disableChecksButton.setOnClickListener { maybeDisableChecks() }
|
||||
.observe(this, Observer {
|
||||
toolbar.menu.findItem(R.id.disableChecks)
|
||||
.isVisible = it
|
||||
})
|
||||
|
||||
// Done button
|
||||
// Done item text
|
||||
viewModel.onDoneButtonText()
|
||||
.toViewText(this, doneBtn)
|
||||
doneBtn.setOnClickListener {
|
||||
viewModel.commit { finish() }
|
||||
.observe(this, Observer {
|
||||
toolbar.menu.findItem(R.id.commit)
|
||||
.setTitle(it)
|
||||
})
|
||||
|
||||
// SSL certificate
|
||||
sslCertificateBrowse.setOnClickListener {
|
||||
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
}
|
||||
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupValidation() {
|
||||
validationForm = form {
|
||||
input(inputName, name = "Name") {
|
||||
isNotEmpty().description(R.string.please_enter_name)
|
||||
}
|
||||
input(inputUrl, name = "URL") {
|
||||
isNotEmpty().description(R.string.please_enter_url)
|
||||
isUrl().description(R.string.please_enter_valid_url)
|
||||
}
|
||||
input(responseValidationSearchTerm, name = "Search term") {
|
||||
conditional(responseValidationSearchTerm.isVisibleCondition()) {
|
||||
isNotEmpty().description(R.string.please_enter_search_term)
|
||||
}
|
||||
}
|
||||
input(responseTimeoutInput, name = "Timeout", optional = true) {
|
||||
isNumber().greaterThan(0)
|
||||
.description(R.string.please_enter_networkTimeout)
|
||||
}
|
||||
input(sslCertificateInput, name = "Certificate Path", optional = true) {
|
||||
isUri().hasScheme("file", "content")
|
||||
.that { it.host != null }
|
||||
.description(R.string.please_enter_validCertUri)
|
||||
}
|
||||
submitWith(toolbar.menu, R.id.commit) {
|
||||
viewModel.commit { finish() }
|
||||
}
|
||||
}
|
||||
|
||||
// Validation script
|
||||
scriptInputLayout.attach(
|
||||
codeData = viewModel.validationScript,
|
||||
visibility = viewModel.onValidationScriptVisibility(),
|
||||
form = validationForm
|
||||
)
|
||||
|
||||
// Check interval
|
||||
checkIntervalLayout.attach(
|
||||
valueData = viewModel.checkIntervalValue,
|
||||
multiplierData = viewModel.checkIntervalUnit,
|
||||
form = validationForm
|
||||
)
|
||||
|
||||
// Retry Policy
|
||||
retryPolicyLayout.attach(
|
||||
timesData = viewModel.retryPolicyTimes,
|
||||
minutesData = viewModel.retryPolicyMinutes,
|
||||
form = validationForm
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
|
||||
appToolbar.dimenFloat(R.dimen.default_elevation)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
resultData: Intent?
|
||||
) {
|
||||
super.onActivityResult(requestCode, resultCode, resultData)
|
||||
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
|
||||
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,8 +23,9 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import com.afollestad.nocknock.R
|
||||
import com.afollestad.nocknock.data.AppDatabase
|
||||
import com.afollestad.nocknock.data.RetryPolicy
|
||||
import com.afollestad.nocknock.data.deleteSite
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.Status
|
||||
import com.afollestad.nocknock.data.model.Status.WAITING
|
||||
|
@ -35,14 +36,13 @@ import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
|
|||
import com.afollestad.nocknock.data.model.ValidationResult
|
||||
import com.afollestad.nocknock.data.model.textRes
|
||||
import com.afollestad.nocknock.data.updateSite
|
||||
import com.afollestad.nocknock.engine.validation.ValidationManager
|
||||
import com.afollestad.nocknock.engine.validation.ValidationExecutor
|
||||
import com.afollestad.nocknock.notifications.NockNotificationManager
|
||||
import com.afollestad.nocknock.ui.ScopedViewModel
|
||||
import com.afollestad.nocknock.utilities.ext.formatDate
|
||||
import com.afollestad.nocknock.utilities.livedata.map
|
||||
import com.afollestad.nocknock.utilities.livedata.zip
|
||||
import com.afollestad.nocknock.utilities.providers.StringProvider
|
||||
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -54,7 +54,7 @@ class ViewSiteViewModel(
|
|||
private val stringProvider: StringProvider,
|
||||
private val database: AppDatabase,
|
||||
private val notificationManager: NockNotificationManager,
|
||||
private val validationManager: ValidationManager,
|
||||
private val validationManager: ValidationExecutor,
|
||||
mainDispatcher: CoroutineDispatcher,
|
||||
private val ioDispatcher: CoroutineDispatcher
|
||||
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
|
||||
|
@ -64,6 +64,7 @@ class ViewSiteViewModel(
|
|||
// Public properties
|
||||
val status = MutableLiveData<Status>()
|
||||
val name = MutableLiveData<String>()
|
||||
val tags = MutableLiveData<String>()
|
||||
val url = MutableLiveData<String>()
|
||||
val timeout = MutableLiveData<Int>()
|
||||
val validationMode = MutableLiveData<ValidationMode>()
|
||||
|
@ -73,25 +74,15 @@ class ViewSiteViewModel(
|
|||
val checkIntervalUnit = MutableLiveData<Long>()
|
||||
val retryPolicyTimes = MutableLiveData<Int>()
|
||||
val retryPolicyMinutes = MutableLiveData<Int>()
|
||||
val headers = MutableLiveData<List<Header>>()
|
||||
val certificateUri = MutableLiveData<String>()
|
||||
internal val disabled = MutableLiveData<Boolean>()
|
||||
internal val lastResult = MutableLiveData<ValidationResult?>()
|
||||
|
||||
// Private properties
|
||||
private val isLoading = MutableLiveData<Boolean>()
|
||||
private val nameError = MutableLiveData<Int?>()
|
||||
private val urlError = MutableLiveData<Int?>()
|
||||
private val timeoutError = MutableLiveData<Int?>()
|
||||
private val validationSearchTermError = MutableLiveData<Int?>()
|
||||
private val validationScriptError = MutableLiveData<Int?>()
|
||||
private val checkIntervalValueError = MutableLiveData<Int?>()
|
||||
|
||||
// Expose private properties or calculated properties
|
||||
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
|
||||
|
||||
@CheckResult fun onNameError(): LiveData<Int?> = nameError
|
||||
|
||||
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
|
||||
|
||||
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
|
||||
return url.map {
|
||||
val parsed = HttpUrl.parse(it)
|
||||
|
@ -99,8 +90,6 @@ class ViewSiteViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
|
||||
|
||||
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
|
||||
return validationMode.map {
|
||||
when (it!!) {
|
||||
|
@ -111,20 +100,11 @@ class ViewSiteViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError
|
||||
@CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
|
||||
|
||||
@CheckResult fun onValidationSearchTermVisibility() =
|
||||
validationMode.map { it == TERM_SEARCH }
|
||||
@CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
|
||||
|
||||
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
|
||||
|
||||
@CheckResult fun onValidationScriptVisibility() =
|
||||
validationMode.map { it == JAVASCRIPT }
|
||||
|
||||
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
|
||||
|
||||
@CheckResult fun onDisableChecksVisibility(): LiveData<Boolean> =
|
||||
disabled.map { !it }
|
||||
@CheckResult fun onDisableChecksVisibility(): LiveData<Boolean> = disabled.map { !it }
|
||||
|
||||
@CheckResult fun onDoneButtonText(): LiveData<Int> =
|
||||
disabled.map {
|
||||
|
@ -168,7 +148,7 @@ class ViewSiteViewModel(
|
|||
withContext(ioDispatcher) {
|
||||
database.updateSite(updatedModel)
|
||||
}
|
||||
validationManager.scheduleCheck(
|
||||
validationManager.scheduleValidation(
|
||||
site = updatedModel,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
|
@ -184,7 +164,7 @@ class ViewSiteViewModel(
|
|||
status = WAITING
|
||||
)
|
||||
setModel(checkModel)
|
||||
validationManager.scheduleCheck(
|
||||
validationManager.scheduleValidation(
|
||||
site = checkModel,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
|
@ -192,7 +172,7 @@ class ViewSiteViewModel(
|
|||
}
|
||||
|
||||
fun removeSite(done: () -> Unit) {
|
||||
validationManager.cancelCheck(site)
|
||||
validationManager.cancelScheduledValidation(site)
|
||||
notificationManager.cancelStatusNotification(site)
|
||||
|
||||
scope.launch {
|
||||
|
@ -206,7 +186,7 @@ class ViewSiteViewModel(
|
|||
}
|
||||
|
||||
fun disableSite() {
|
||||
validationManager.cancelCheck(site)
|
||||
validationManager.cancelScheduledValidation(site)
|
||||
notificationManager.cancelStatusNotification(site)
|
||||
|
||||
scope.launch {
|
||||
|
@ -242,75 +222,16 @@ class ViewSiteViewModel(
|
|||
}
|
||||
|
||||
private fun getUpdatedDbModel(): Site? {
|
||||
var errorCount = 0
|
||||
|
||||
// Validation name
|
||||
if (name.value.isNullOrEmpty()) {
|
||||
nameError.value = R.string.please_enter_name
|
||||
errorCount++
|
||||
} else {
|
||||
nameError.value = null
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
when {
|
||||
url.value.isNullOrEmpty() -> {
|
||||
urlError.value = R.string.please_enter_url
|
||||
errorCount++
|
||||
}
|
||||
HttpUrl.parse(url.value!!) == null -> {
|
||||
urlError.value = R.string.please_enter_valid_url
|
||||
errorCount++
|
||||
}
|
||||
else -> {
|
||||
urlError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Validate timeout
|
||||
if (timeout.value.isNullOrLessThan(1)) {
|
||||
timeoutError.value = R.string.please_enter_networkTimeout
|
||||
errorCount++
|
||||
} else {
|
||||
timeoutError.value = null
|
||||
}
|
||||
|
||||
// Validate check interval
|
||||
if (checkIntervalValue.value.isNullOrLessThan(1)) {
|
||||
checkIntervalValueError.value = R.string.please_enter_check_interval
|
||||
errorCount++
|
||||
} else {
|
||||
checkIntervalValueError.value = null
|
||||
}
|
||||
|
||||
// Validate arguments
|
||||
if (validationMode.value == TERM_SEARCH &&
|
||||
validationSearchTerm.value.isNullOrEmpty()
|
||||
) {
|
||||
errorCount++
|
||||
validationSearchTermError.value = R.string.please_enter_search_term
|
||||
validationScriptError.value = null
|
||||
} else if (validationMode.value == JAVASCRIPT &&
|
||||
validationScript.value.isNullOrEmpty()
|
||||
) {
|
||||
errorCount++
|
||||
validationSearchTermError.value = null
|
||||
validationScriptError.value = R.string.please_enter_javaScript
|
||||
} else {
|
||||
validationSearchTermError.value = null
|
||||
validationScriptError.value = null
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
return null
|
||||
}
|
||||
val timeout = timeout.value ?: 10_000
|
||||
val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: ""
|
||||
|
||||
val newSettings = site.settings!!.copy(
|
||||
validationIntervalMs = getCheckIntervalMs(),
|
||||
validationMode = validationMode.value!!,
|
||||
validationArgs = getValidationArgs(),
|
||||
networkTimeout = timeout.value!!,
|
||||
disabled = false
|
||||
networkTimeout = timeout,
|
||||
disabled = false,
|
||||
certificate = certificateUri.value?.toString()
|
||||
)
|
||||
|
||||
val retryPolicyTimes = retryPolicyTimes.value ?: 0
|
||||
|
@ -318,10 +239,16 @@ class ViewSiteViewModel(
|
|||
val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
|
||||
if (site.retryPolicy != null) {
|
||||
// Have existing policy, update it
|
||||
site.retryPolicy!!.copy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
|
||||
site.retryPolicy!!.copy(
|
||||
count = retryPolicyTimes,
|
||||
minutes = retryPolicyMinutes
|
||||
)
|
||||
} else {
|
||||
// Create new policy
|
||||
RetryPolicy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
|
||||
RetryPolicy(
|
||||
count = retryPolicyTimes,
|
||||
minutes = retryPolicyMinutes
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No policy
|
||||
|
@ -330,9 +257,11 @@ class ViewSiteViewModel(
|
|||
|
||||
return site.copy(
|
||||
name = name.value!!.trim(),
|
||||
tags = cleanedTags,
|
||||
url = url.value!!.trim(),
|
||||
settings = newSettings,
|
||||
retryPolicy = retryPolicy
|
||||
retryPolicy = retryPolicy,
|
||||
headers = headers.value ?: emptyList()
|
||||
)
|
||||
.withStatus(status = WAITING)
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
package com.afollestad.nocknock.ui.viewsite
|
||||
|
||||
import com.afollestad.nocknock.data.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.Status.WAITING
|
||||
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
|
||||
|
@ -32,6 +32,7 @@ fun ViewSiteViewModel.setModel(site: Site) {
|
|||
|
||||
status.value = site.lastResult?.status ?: WAITING
|
||||
name.value = site.name
|
||||
tags.value = site.tags
|
||||
url.value = site.url
|
||||
timeout.value = settings.networkTimeout
|
||||
|
||||
|
@ -53,6 +54,12 @@ fun ViewSiteViewModel.setModel(site: Site) {
|
|||
|
||||
setCheckInterval(settings.validationIntervalMs)
|
||||
setRetryPolicy(site.retryPolicy)
|
||||
headers.value = site.headers
|
||||
if (settings.certificate == "null") {
|
||||
certificateUri.value = ""
|
||||
} else {
|
||||
certificateUri.value = settings.certificate
|
||||
}
|
||||
|
||||
this.disabled.value = settings.disabled
|
||||
this.lastResult.value = site.lastResult
|
||||
|
@ -62,22 +69,22 @@ private fun ViewSiteViewModel.setCheckInterval(interval: Long) {
|
|||
when {
|
||||
interval >= WEEK -> {
|
||||
checkIntervalValue.value =
|
||||
getIntervalFromUnit(interval, WEEK)
|
||||
getIntervalFromUnit(interval, WEEK)
|
||||
checkIntervalUnit.value = WEEK
|
||||
}
|
||||
interval >= DAY -> {
|
||||
checkIntervalValue.value =
|
||||
getIntervalFromUnit(interval, DAY)
|
||||
getIntervalFromUnit(interval, DAY)
|
||||
checkIntervalUnit.value = DAY
|
||||
}
|
||||
interval >= HOUR -> {
|
||||
checkIntervalValue.value =
|
||||
getIntervalFromUnit(interval, HOUR)
|
||||
getIntervalFromUnit(interval, HOUR)
|
||||
checkIntervalUnit.value = HOUR
|
||||
}
|
||||
interval >= MINUTE -> {
|
||||
checkIntervalValue.value =
|
||||
getIntervalFromUnit(interval, MINUTE)
|
||||
getIntervalFromUnit(interval, MINUTE)
|
||||
checkIntervalUnit.value = MINUTE
|
||||
}
|
||||
else -> {
|
||||
|
|
5
app/src/main/res/color/unchecked_chip_text.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?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>
|
13
app/src/main/res/drawable/checked_chip.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?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>
|
13
app/src/main/res/drawable/checked_chip_pressed.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?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>
|
5
app/src/main/res/drawable/checked_chip_selector.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?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>
|
10
app/src/main/res/drawable/ic_check.xml
Normal file
|
@ -0,0 +1,10 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0"
|
||||
android:width="24dp">
|
||||
<path
|
||||
android:fillColor="?iconColor"
|
||||
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
|
||||
</vector>
|
13
app/src/main/res/drawable/unchecked_chip.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?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>
|
13
app/src/main/res/drawable/unchecked_chip_pressed.xml
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?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>
|
5
app/src/main/res/drawable/unchecked_chip_selector.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?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>
|
|
@ -16,6 +16,7 @@
|
|||
<include layout="@layout/include_app_bar"/>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scrollView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
>
|
||||
|
@ -24,59 +25,61 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="@dimen/content_inset"
|
||||
android:paddingBottom="@dimen/content_inset_double"
|
||||
android:paddingLeft="@dimen/content_inset"
|
||||
android:paddingRight="@dimen/content_inset"
|
||||
android:paddingTop="@dimen/content_inset_half"
|
||||
>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/nameTiLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="-4dp"
|
||||
android:layout_marginRight="-4dp"
|
||||
android:layout_marginTop="@dimen/content_inset"
|
||||
>
|
||||
<TextView
|
||||
android:layout_marginTop="0dp"
|
||||
android:text="@string/site_name"
|
||||
style="@style/InputForm.Header"
|
||||
/>
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/inputName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/site_name"
|
||||
android:inputType="textPersonName|textCapWords|textAutoCorrect"
|
||||
style="@style/NockText.Body"
|
||||
/>
|
||||
<EditText
|
||||
android:id="@+id/inputName"
|
||||
android:hint="@string/site_name_hint"
|
||||
android:inputType="textPersonName|textCapWords|textAutoCorrect"
|
||||
android:nextFocusDown="@+id/inputUrl"
|
||||
tools:ignore="Autofill"
|
||||
style="@style/InputForm.Field"
|
||||
/>
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<TextView
|
||||
android:text="@string/site_url"
|
||||
style="@style/InputForm.Header"
|
||||
/>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/urlTiLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="-4dp"
|
||||
android:layout_marginRight="-4dp"
|
||||
android:layout_marginTop="@dimen/content_inset_half"
|
||||
>
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/inputUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/site_url"
|
||||
android:inputType="textUri"
|
||||
style="@style/NockText.Body"
|
||||
/>
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<EditText
|
||||
android:id="@+id/inputUrl"
|
||||
android:hint="@string/site_url_hint"
|
||||
android:inputType="textUri"
|
||||
android:nextFocusDown="@+id/inputTags"
|
||||
tools:ignore="Autofill"
|
||||
style="@style/InputForm.Field"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textUrlWarning"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/list_text_spacing"
|
||||
android:text="@string/warning_http_url"
|
||||
android:visibility="gone"
|
||||
style="@style/NockText.Footnote"
|
||||
style="@style/InputForm.FieldNote"
|
||||
/>
|
||||
|
||||
<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"/>
|
||||
|
@ -88,35 +91,10 @@
|
|||
android:layout_marginTop="@dimen/content_inset"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset_less"
|
||||
android:text="@string/response_timeout"
|
||||
style="@style/NockText.SectionHeader"
|
||||
/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/responseTimeoutInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="-4dp"
|
||||
android:layout_marginStart="-4dp"
|
||||
android:layout_marginTop="@dimen/content_inset_quarter"
|
||||
android:hint="@string/response_timeout_default"
|
||||
android:inputType="number"
|
||||
android:maxLength="8"
|
||||
tools:ignore="Autofill,HardcodedText,LabelFor"
|
||||
style="@style/NockText.Body"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/responseValidationLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset_more"
|
||||
android:text="@string/response_validation_mode"
|
||||
style="@style/NockText.SectionHeader"
|
||||
style="@style/InputForm.Header"
|
||||
/>
|
||||
|
||||
<Spinner
|
||||
|
@ -145,7 +123,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/content_inset"
|
||||
android:layout_marginTop="@dimen/content_inset_half"
|
||||
android:background="@color/lighterGray"
|
||||
android:background="?scriptLayoutBackground"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
|
@ -158,6 +136,8 @@
|
|||
style="@style/NockText.Body.Light"
|
||||
/>
|
||||
|
||||
<include layout="@layout/include_divider"/>
|
||||
|
||||
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
|
||||
android:id="@+id/retryPolicyLayout"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -165,13 +145,66 @@
|
|||
android:layout_marginTop="@dimen/content_inset_more"
|
||||
/>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/doneBtn"
|
||||
<TextView
|
||||
android:layout_marginTop="@dimen/content_inset"
|
||||
android:text="@string/response_timeout"
|
||||
style="@style/InputForm.Header"
|
||||
/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/responseTimeoutInput"
|
||||
android:hint="@string/response_timeout_default"
|
||||
android:inputType="number"
|
||||
android:maxLength="8"
|
||||
tools:ignore="Autofill"
|
||||
style="@style/InputForm.Field"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset"
|
||||
android:text="@string/ssl_certificate"
|
||||
style="@style/NockText.SectionHeader"
|
||||
/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset_double"
|
||||
android:text="@string/add_site"
|
||||
style="@style/AccentButton"
|
||||
android:orientation="horizontal"
|
||||
>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/sslCertificateInput"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:layout_marginStart="-4dp"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/ssl_certificate_automatic"
|
||||
android:inputType="textUri"
|
||||
tools:ignore="Autofill,HardcodedText,LabelFor"
|
||||
style="@style/NockText.Body"
|
||||
/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/sslCertificateBrowse"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:text="@string/ssl_certificate_browse"
|
||||
style="@style/AccentTextButton"
|
||||
/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<include layout="@layout/include_divider"/>
|
||||
|
||||
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
|
||||
android:id="@+id/headersLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset_half"
|
||||
/>
|
||||
|
||||
</LinearLayout>
|
||||
|
|
|
@ -17,6 +17,19 @@
|
|||
|
||||
<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
|
||||
android:id="@+id/list"
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -26,15 +26,30 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="@dimen/content_inset"
|
||||
android:paddingBottom="@dimen/content_inset_double"
|
||||
android:paddingLeft="@dimen/content_inset"
|
||||
android:paddingRight="@dimen/content_inset"
|
||||
android:paddingTop="@dimen/content_inset"
|
||||
android:paddingTop="@dimen/content_inset_less"
|
||||
>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/inputName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@null"
|
||||
android:hint="@string/site_name"
|
||||
android:inputType="textPersonName|textCapWords|textAutoCorrect"
|
||||
android:nextFocusDown="@+id/inputUrl"
|
||||
android:singleLine="true"
|
||||
android:transitionName="site_name"
|
||||
tools:ignore="Autofill,UnusedAttribute"
|
||||
style="@style/NockText.Header"
|
||||
/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset_quarter"
|
||||
android:orientation="horizontal"
|
||||
>
|
||||
|
||||
|
@ -55,24 +70,13 @@
|
|||
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
|
||||
android:id="@+id/inputUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/site_url"
|
||||
android:inputType="textUri"
|
||||
android:nextFocusDown="@+id/inputTags"
|
||||
android:singleLine="true"
|
||||
android:transitionName="site_url"
|
||||
tools:ignore="Autofill,UnusedAttribute"
|
||||
|
@ -91,6 +95,19 @@
|
|||
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>
|
||||
|
@ -109,35 +126,6 @@
|
|||
android:layout_marginTop="@dimen/content_inset"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset"
|
||||
android:text="@string/response_timeout"
|
||||
style="@style/NockText.SectionHeader"
|
||||
/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/responseTimeoutInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="-4dp"
|
||||
android:layout_marginStart="-4dp"
|
||||
android: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
|
||||
android:id="@+id/responseValidationLabel"
|
||||
android:layout_width="wrap_content"
|
||||
|
@ -173,7 +161,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="@dimen/content_inset"
|
||||
android:layout_marginTop="@dimen/content_inset_half"
|
||||
android:background="@color/lighterGray"
|
||||
android:background="?scriptLayoutBackground"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
|
@ -186,6 +174,13 @@
|
|||
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
|
||||
android:id="@+id/retryPolicyLayout"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -193,6 +188,76 @@
|
|||
android:layout_marginTop="@dimen/content_inset"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset"
|
||||
android:text="@string/response_timeout"
|
||||
style="@style/NockText.SectionHeader"
|
||||
/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/responseTimeoutInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="-4dp"
|
||||
android:layout_marginStart="-4dp"
|
||||
android:layout_marginTop="@dimen/content_inset_quarter"
|
||||
android:hint="@string/response_timeout_default"
|
||||
android:inputType="number"
|
||||
android:maxLength="8"
|
||||
tools:ignore="Autofill,HardcodedText,LabelFor"
|
||||
style="@style/NockText.Body"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset"
|
||||
android:text="@string/ssl_certificate"
|
||||
style="@style/NockText.SectionHeader"
|
||||
/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/sslCertificateInput"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:layout_marginStart="-4dp"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/ssl_certificate_automatic"
|
||||
android:inputType="textUri"
|
||||
tools:ignore="Autofill,HardcodedText,LabelFor"
|
||||
style="@style/NockText.Body"
|
||||
/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/sslCertificateBrowse"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:text="@string/ssl_certificate_browse"
|
||||
style="@style/AccentTextButton"
|
||||
/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<include layout="@layout/include_divider"/>
|
||||
|
||||
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
|
||||
android:id="@+id/headersLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset_half"
|
||||
/>
|
||||
|
||||
|
||||
<include layout="@layout/include_divider"/>
|
||||
|
||||
<TextView
|
||||
|
@ -229,24 +294,6 @@
|
|||
style="@style/NockText.Body"
|
||||
/>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/doneBtn"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset_double"
|
||||
android:text="@string/save_changes"
|
||||
style="@style/AccentButton"
|
||||
/>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/disableChecksButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/content_inset_quarter"
|
||||
android:text="@string/disable_automatic_checks"
|
||||
style="@style/PrimaryDarkButton"
|
||||
/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
|
15
app/src/main/res/layout/list_item_tag.xml
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?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"
|
||||
/>
|
9
app/src/main/res/menu/menu_addsite.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/commit"
|
||||
android:icon="@drawable/ic_check"
|
||||
android:title="@string/add_site"
|
||||
app:showAsAction="ifRoom"/>
|
||||
</menu>
|
|
@ -7,7 +7,4 @@
|
|||
android:id="@+id/dark_mode"
|
||||
android:checkable="true"
|
||||
android:title="@string/dark_mode"/>
|
||||
<item
|
||||
android:id="@+id/support_me"
|
||||
android:title="@string/support_me"/>
|
||||
</menu>
|
||||
|
|
|
@ -1,17 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/commit"
|
||||
android:icon="@drawable/ic_check"
|
||||
android:title="@string/save_changes"
|
||||
app:showAsAction="ifRoom"/>
|
||||
<item
|
||||
android:id="@+id/refresh"
|
||||
android:icon="@drawable/ic_action_refresh"
|
||||
android:title="@string/refresh_status"
|
||||
app:showAsAction="ifRoom"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/remove"
|
||||
android:icon="@drawable/ic_action_delete"
|
||||
android:title="@string/remove_site"
|
||||
app:showAsAction="ifRoom"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/disableChecks"
|
||||
android:title="@string/disable_automatic_checks"
|
||||
/>
|
||||
</menu>
|
||||
|
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.4 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 5.4 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 9 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 7.7 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 15 KiB |
|
@ -3,6 +3,7 @@
|
|||
|
||||
<string-array name="site_long_options" translatable="false">
|
||||
<item>@string/refresh_status</item>
|
||||
<item>@string/duplicate_and_modify</item>
|
||||
<item>@string/remove_site</item>
|
||||
</string-array>
|
||||
|
||||
|
@ -12,10 +13,4 @@
|
|||
<item>JavaScript Evaluation</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="donation_options">
|
||||
<item>via PayPal</item>
|
||||
<item>via Cash App</item>
|
||||
<item>via Venmo</item>
|
||||
</string-array>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -4,5 +4,6 @@
|
|||
<attr format="color" name="toolbarTitleColor"/>
|
||||
<attr format="color" name="dividerColor"/>
|
||||
<attr format="color" name="iconColor"/>
|
||||
<attr format="color" name="scriptLayoutBackground"/>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -7,7 +7,11 @@
|
|||
<color name="colorPrimary_darkTheme">#212121</color>
|
||||
<color name="colorPrimaryDark_darkTheme">#252525</color>
|
||||
|
||||
<color name="lighterGray">#303030</color>
|
||||
<color name="darkerGray">#303030</color>
|
||||
<color name="lighterGray">#EEEEEE</color>
|
||||
|
||||
<color name="colorAccent">#FF6E40</color>
|
||||
<color name="colorAccent_pressed">#E44615</color>
|
||||
<color name="colorAccent_translucent">#40FF6E40</color>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -2,5 +2,6 @@
|
|||
|
||||
<dimen name="empty_text_size">28sp</dimen>
|
||||
<dimen name="list_text_spacing">6dp</dimen>
|
||||
<dimen name="toolbar_elevation">4dp</dimen>
|
||||
|
||||
</resources>
|
||||
|
|
4
app/src/main/res/values/ic_launcher_background.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#758F9A</color>
|
||||
</resources>
|
|
@ -1,35 +1,42 @@
|
|||
<resources>
|
||||
|
||||
<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="about">About</string>
|
||||
<string name="about_body"><![CDATA[
|
||||
<b>Nock Nock</b>, a simple app designed by <b>Aidan Follestad</b>.<br/>
|
||||
A simple app designed by <b>Aidan Follestad</b>.<br/>
|
||||
<a href=\'https://af.codes\'>Website</a>
|
||||
<a href=\'https://twitter.com/afollestad\'>Twitter</a>
|
||||
<a href=\'https://github.com/afollestad\'>GitHub</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/>Icon by <a href=\'https://plus.google.com/+KevinAguilarC\'>Kevin Aguilar</a> of <b>221 Pixels</b>.
|
||||
<br/>View the <a href=\'https://af.codes/privacypolicies/nocknock.html\'>Privacy Policy</a>.
|
||||
]]></string>
|
||||
<string name="dark_mode">Dark Mode</string>
|
||||
|
||||
<string name="dismiss">Dismiss</string>
|
||||
<string name="add_site">Add Site</string>
|
||||
<string name="site_name">Site Name</string>
|
||||
<string name="site_name_hint">Site display name</string>
|
||||
<string name="site_url">Site URL</string>
|
||||
<string name="site_url_hint">https://yoursite.com</string>
|
||||
<string name="site_tags">Site Tags</string>
|
||||
<string name="site_tags_hint">e.g. One,Two,Three</string>
|
||||
<string name="site_tags_hint_full">Tags (e.g. One,Two,Three)</string>
|
||||
<string name="please_enter_name">Please enter a name!</string>
|
||||
<string name="please_enter_url">Please enter a URL.</string>
|
||||
<string name="please_enter_valid_url">Please enter a valid URL.</string>
|
||||
<string name="please_enter_check_interval">Please input a validation interval.</string>
|
||||
<string name="please_enter_search_term">Please input a search term.</string>
|
||||
<string name="please_enter_javaScript">Please input a validation script.</string>
|
||||
<string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string>
|
||||
<string name="please_enter_validCertUri">Certificate should be a valid file or content URI.</string>
|
||||
|
||||
<string name="options">Options</string>
|
||||
<string name="remove_site">Remove Site</string>
|
||||
<string name="duplicate_and_modify">Duplicate and Modify</string>
|
||||
<string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="save_changes">Save Changes</string>
|
||||
|
@ -43,8 +50,8 @@
|
|||
<string name="disable_automatic_checks">Disable Automatic Validation</string>
|
||||
<string name="disable_automatic_checks_prompt"><![CDATA[
|
||||
Disable automatic validation for <b>%1$s</b>? The site will not be checked in the background
|
||||
until you re-enable validation for it. You can still manually perform validation by tapping the
|
||||
Refresh icon at the top of this page.
|
||||
until you re-enable validation by tapping the checkmark (Save) icon. You can still manually
|
||||
perform validation by tapping the Refresh icon at the top of this page.
|
||||
]]></string>
|
||||
<string name="disable">Disable</string>
|
||||
<string name="renable_and_save_changes">Enable Auto Validation & Save Changes</string>
|
||||
|
@ -52,6 +59,10 @@
|
|||
<string name="response_timeout">Network Response Timeout (ms)</string>
|
||||
<string name="response_timeout_default">10000</string>
|
||||
|
||||
<string name="ssl_certificate">SSL Certificate</string>
|
||||
<string name="ssl_certificate_automatic">(Automatic)</string>
|
||||
<string name="ssl_certificate_browse">Browse</string>
|
||||
|
||||
<string name="refresh_status">Refresh Status</string>
|
||||
|
||||
<string name="warning_http_url">
|
||||
|
@ -74,14 +85,6 @@
|
|||
exception to pass custom error messages to Nock Nock.
|
||||
</string>
|
||||
|
||||
<string name="support_me">Donate</string>
|
||||
<string name="support_me_message"><![CDATA[
|
||||
<b>Nock Nock</b> was created and is maintained by one person. Donations are <b>much</b>
|
||||
appreciated and encourage continued support.
|
||||
]]></string>
|
||||
<string name="thank_you">Thank you very much!</string>
|
||||
<string name="next">Next</string>
|
||||
|
||||
<string name="install_web_browser">Please install a web browser app, such as Google Chrome.</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -4,15 +4,9 @@
|
|||
|
||||
<style name="AppTheme.Dark" parent="AppThemeParent.Dark"/>
|
||||
|
||||
<style name="AccentButton" parent="Widget.MaterialComponents.Button">
|
||||
<item name="android:textColor">#fff</item>
|
||||
<item name="backgroundTint">@color/colorAccent</item>
|
||||
<item name="android:fontFamily">@font/lato</item>
|
||||
</style>
|
||||
|
||||
<style name="PrimaryDarkButton" parent="Widget.MaterialComponents.Button">
|
||||
<item name="android:textColor">#fff</item>
|
||||
<item name="backgroundTint">@color/lighterGray</item>
|
||||
<item name="backgroundTint">@color/darkerGray</item>
|
||||
<item name="android:fontFamily">@font/lato</item>
|
||||
</style>
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<item name="toolbarTitleColor">#000000</item>
|
||||
<item name="dividerColor">#EEEEEE</item>
|
||||
<item name="iconColor">#000000</item>
|
||||
<item name="scriptLayoutBackground">@color/lighterGray</item>
|
||||
|
||||
<item name="android:textColorPrimary">#212121</item>
|
||||
<item name="android:textColorSecondary">#727272</item>
|
||||
|
@ -33,6 +34,7 @@
|
|||
<item name="toolbarTitleColor">#ffffff</item>
|
||||
<item name="dividerColor">#303030</item>
|
||||
<item name="iconColor">#FFFFFF</item>
|
||||
<item name="scriptLayoutBackground">@color/darkerGray</item>
|
||||
|
||||
<item name="android:textColorPrimary">#FFFFFF</item>
|
||||
<item name="android:textColorSecondary">#F0F0F0</item>
|
||||
|
|
|
@ -6,4 +6,28 @@
|
|||
<item name="android:textColor">?toolbarTitleColor</item>
|
||||
</style>
|
||||
|
||||
<style name="InputForm"/>
|
||||
|
||||
<style name="InputForm.Header" parent="NockText.SectionHeader">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_marginTop">@dimen/content_inset_less</item>
|
||||
</style>
|
||||
|
||||
<style name="InputForm.Field" parent="NockText.Body">
|
||||
<item name="android:layout_width">match_parent</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_marginTop">@dimen/content_inset_quarter</item>
|
||||
<item name="android:singleLine">true</item>
|
||||
<item name="android:imeOptions">actionNext</item>
|
||||
<item name="android:layout_marginStart">-4dp</item>
|
||||
<item name="android:layout_marginEnd">-4dp</item>
|
||||
</style>
|
||||
|
||||
<style name="InputForm.FieldNote" parent="NockText.Footnote">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_marginTop">@dimen/list_text_spacing</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -19,11 +19,13 @@ import android.app.PendingIntent
|
|||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import com.afollestad.nocknock.data.AppDatabase
|
||||
import com.afollestad.nocknock.data.RetryPolicy
|
||||
import com.afollestad.nocknock.data.HeaderDao
|
||||
import com.afollestad.nocknock.data.RetryPolicyDao
|
||||
import com.afollestad.nocknock.data.SiteDao
|
||||
import com.afollestad.nocknock.data.SiteSettingsDao
|
||||
import com.afollestad.nocknock.data.ValidationResultsDao
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.SiteSettings
|
||||
import com.afollestad.nocknock.data.model.Status
|
||||
|
@ -55,7 +57,8 @@ fun fakeSettingsModel(
|
|||
validationMode = validationMode,
|
||||
validationArgs = null,
|
||||
disabled = false,
|
||||
networkTimeout = 10000
|
||||
networkTimeout = 10000,
|
||||
certificate = null
|
||||
)
|
||||
|
||||
fun fakeResultModel(
|
||||
|
@ -79,18 +82,31 @@ fun fakeRetryPolicy(
|
|||
minutes = minutes
|
||||
)
|
||||
|
||||
fun fakeModel(id: Long) = Site(
|
||||
fun fakeHeaders(siteId: Long): List<Header> {
|
||||
return listOf(
|
||||
Header(id = siteId + 1, siteId = siteId, key = "Content-Type", value = "text/html"),
|
||||
Header(id = siteId + 2, siteId = siteId, key = "User-Agent", value = "NockNock")
|
||||
)
|
||||
}
|
||||
|
||||
fun fakeModel(
|
||||
id: Long,
|
||||
tags: String = ""
|
||||
) = Site(
|
||||
id = id,
|
||||
name = "Test",
|
||||
url = "https://test.com",
|
||||
tags = tags,
|
||||
settings = fakeSettingsModel(id),
|
||||
lastResult = fakeResultModel(id),
|
||||
retryPolicy = fakeRetryPolicy(id)
|
||||
retryPolicy = fakeRetryPolicy(id),
|
||||
headers = fakeHeaders(id)
|
||||
)
|
||||
|
||||
val MOCK_MODEL_1 = fakeModel(1)
|
||||
val MOCK_MODEL_2 = fakeModel(2)
|
||||
val MOCK_MODEL_3 = fakeModel(3)
|
||||
val MOCK_MODEL_1 = fakeModel(1, tags = "one,two")
|
||||
val MOCK_MODEL_2 = fakeModel(2, tags = "three,four")
|
||||
val MOCK_MODEL_3 = fakeModel(3, tags = "five,six")
|
||||
|
||||
val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3)
|
||||
|
||||
fun mockDatabase(): AppDatabase {
|
||||
|
@ -155,12 +171,29 @@ fun mockDatabase(): AppDatabase {
|
|||
on { update(isA()) } doReturn 1
|
||||
on { delete(isA()) } doReturn 1
|
||||
}
|
||||
val headerDao = mock<HeaderDao> {
|
||||
on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
|
||||
on { forSite(isA()) } doAnswer { inv ->
|
||||
val id = inv.getArgument<Long>(0)
|
||||
return@doAnswer when (id) {
|
||||
1L -> MOCK_MODEL_1.headers
|
||||
2L -> MOCK_MODEL_2.headers
|
||||
3L -> MOCK_MODEL_3.headers
|
||||
else -> listOf()
|
||||
}
|
||||
}
|
||||
on { insert(isA<Header>()) } doReturn 1L
|
||||
on { insert(isA<List<Header>>()) } doReturn listOf(1L, 2L)
|
||||
on { update(isA()) } doReturn 1
|
||||
on { delete(isA()) } doReturn 1
|
||||
}
|
||||
|
||||
return mock {
|
||||
on { siteDao() } doReturn siteDao
|
||||
on { siteSettingsDao() } doReturn settingsDao
|
||||
on { validationResultsDao() } doReturn resultsDao
|
||||
on { retryPolicyDao() } doReturn retryDao
|
||||
on { headerDao() } doReturn headerDao
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,20 +17,21 @@ package com.afollestad.nocknock.ui.addsite
|
|||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.afollestad.nocknock.R
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.SiteSettings
|
||||
import com.afollestad.nocknock.data.model.Status.WAITING
|
||||
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
|
||||
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
|
||||
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
|
||||
import com.afollestad.nocknock.engine.validation.ValidationManager
|
||||
import com.afollestad.nocknock.data.model.ValidationResult
|
||||
import com.afollestad.nocknock.engine.validation.ValidationExecutor
|
||||
import com.afollestad.nocknock.mockDatabase
|
||||
import com.afollestad.nocknock.utilities.ext.MINUTE
|
||||
import com.afollestad.nocknock.utilities.livedata.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.nhaarman.mockitokotlin2.any
|
||||
import com.nhaarman.mockitokotlin2.argumentCaptor
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import com.nhaarman.mockitokotlin2.never
|
||||
import com.nhaarman.mockitokotlin2.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
@ -44,7 +45,7 @@ import org.junit.Test
|
|||
class AddSiteViewModelTest {
|
||||
|
||||
private val database = mockDatabase()
|
||||
private val validationManager = mock<ValidationManager>()
|
||||
private val validationManager = mock<ValidationExecutor>()
|
||||
|
||||
@Rule @JvmField val rule = InstantTaskExecutorRule()
|
||||
|
||||
|
@ -149,247 +150,9 @@ class AddSiteViewModelTest {
|
|||
assertThat(viewModel.getValidationArgs()).isEqualTo("Two")
|
||||
}
|
||||
|
||||
@Test fun commit_nameError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
name.value = ""
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertValues(R.string.please_enter_name)
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_urlEmptyError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
url.value = ""
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertValues(R.string.please_enter_url)
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_urlFormatError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
url.value = "ftp://www.idk.com"
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertValues(R.string.please_enter_valid_url)
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_networkTimeout_error() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
timeout.value = 0
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertValues(R.string.please_enter_networkTimeout)
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_checkIntervalError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
checkIntervalValue.value = 0
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertValues(R.string.please_enter_check_interval)
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_termSearchError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
validationMode.value = TERM_SEARCH
|
||||
validationSearchTerm.value = ""
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertValues(R.string.please_enter_search_term)
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_javaScript_error() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
validationMode.value = JAVASCRIPT
|
||||
validationScript.value = ""
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertValues(R.string.please_enter_javaScript)
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_success() = runBlocking {
|
||||
val isLoading = viewModel.onIsLoading()
|
||||
.test()
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
|
||||
fillInModel()
|
||||
val onDone = mock<() -> Unit>()
|
||||
|
@ -397,31 +160,30 @@ class AddSiteViewModelTest {
|
|||
|
||||
val siteCaptor = argumentCaptor<Site>()
|
||||
val settingsCaptor = argumentCaptor<SiteSettings>()
|
||||
val validationResultCaptor = argumentCaptor<ValidationResult>()
|
||||
|
||||
isLoading.assertValues(true, false)
|
||||
verify(database.siteDao()).insert(siteCaptor.capture())
|
||||
verify(database.siteSettingsDao()).insert(settingsCaptor.capture())
|
||||
verify(database.validationResultsDao(), never()).insert(any())
|
||||
verify(database.validationResultsDao()).insert(validationResultCaptor.capture())
|
||||
|
||||
val settings = settingsCaptor.firstValue
|
||||
val result = validationResultCaptor.firstValue.copy(siteId = 1)
|
||||
val model = siteCaptor.firstValue.copy(
|
||||
id = 1, // fill it in because our insert captor doesn't catch this
|
||||
settings = settings,
|
||||
lastResult = null
|
||||
lastResult = result
|
||||
)
|
||||
|
||||
verify(validationManager).scheduleCheck(
|
||||
assertThat(result.reason).isNull()
|
||||
assertThat(result.status).isEqualTo(WAITING)
|
||||
|
||||
verify(validationManager).scheduleValidation(
|
||||
site = model,
|
||||
rightNow = true,
|
||||
cancelPrevious = true,
|
||||
fromFinishingJob = false
|
||||
)
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone).invoke()
|
||||
}
|
||||
|
@ -435,5 +197,10 @@ class AddSiteViewModelTest {
|
|||
validationScript.value = null
|
||||
checkIntervalValue.value = 60
|
||||
checkIntervalUnit.value = 1000
|
||||
tags.value = "one,two"
|
||||
headers.value = listOf(
|
||||
Header(2L, 1L, key = "Content-Type", value = "text/html"),
|
||||
Header(3L, 1L, key = "User-Agent", value = "NockNock")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import com.afollestad.nocknock.ALL_MOCK_MODELS
|
|||
import com.afollestad.nocknock.MOCK_MODEL_1
|
||||
import com.afollestad.nocknock.MOCK_MODEL_2
|
||||
import com.afollestad.nocknock.MOCK_MODEL_3
|
||||
import com.afollestad.nocknock.engine.validation.ValidationManager
|
||||
import com.afollestad.nocknock.engine.validation.ValidationExecutor
|
||||
import com.afollestad.nocknock.mockDatabase
|
||||
import com.afollestad.nocknock.notifications.NockNotificationManager
|
||||
import com.afollestad.nocknock.utilities.livedata.test
|
||||
|
@ -39,7 +39,7 @@ class MainViewModelTest {
|
|||
|
||||
private val database = mockDatabase()
|
||||
private val notificationManager = mock<NockNotificationManager>()
|
||||
private val validationManager = mock<ValidationManager>()
|
||||
private val validationManager = mock<ValidationExecutor>()
|
||||
|
||||
@Rule @JvmField val rule = InstantTaskExecutorRule()
|
||||
|
||||
|
@ -60,18 +60,45 @@ class MainViewModelTest {
|
|||
.test()
|
||||
val sites = viewModel.onSites()
|
||||
.test()
|
||||
val tags = viewModel.onTags()
|
||||
.test()
|
||||
val tagsVisibility = viewModel.onTagsListVisibility()
|
||||
.test()
|
||||
|
||||
viewModel.onResume()
|
||||
|
||||
verify(notificationManager).cancelStatusNotifications()
|
||||
verify(validationManager).ensureScheduledChecks()
|
||||
verify(validationManager).ensureScheduledValidations()
|
||||
|
||||
sites.assertValues(
|
||||
listOf(),
|
||||
ALL_MOCK_MODELS
|
||||
)
|
||||
sites.assertValues(ALL_MOCK_MODELS)
|
||||
isLoading.assertValues(true, false)
|
||||
emptyTextVisibility.assertValues(false, false)
|
||||
tags.assertValues(listOf("one", "two", "three", "four", "five", "six").sorted())
|
||||
tagsVisibility.assertValues(true)
|
||||
}
|
||||
|
||||
@Test fun onTagSelection() = runBlocking {
|
||||
val isLoading = viewModel.onIsLoading()
|
||||
.test()
|
||||
val emptyTextVisibility = viewModel.onEmptyTextVisibility()
|
||||
.test()
|
||||
val sites = viewModel.onSites()
|
||||
.test()
|
||||
val tags = viewModel.onTags()
|
||||
.test()
|
||||
val tagsVisibility = viewModel.onTagsListVisibility()
|
||||
.test()
|
||||
|
||||
viewModel.onTagSelection(listOf("four", "six"))
|
||||
|
||||
verify(notificationManager).cancelStatusNotifications()
|
||||
verify(validationManager).ensureScheduledValidations()
|
||||
|
||||
sites.assertValues(listOf(MOCK_MODEL_2, MOCK_MODEL_3))
|
||||
isLoading.assertValues(true, false)
|
||||
emptyTextVisibility.assertValues(false, false)
|
||||
tags.assertValues(listOf("one", "two", "three", "four", "five", "six").sorted())
|
||||
tagsVisibility.assertValues(true)
|
||||
}
|
||||
|
||||
@Test fun postSiteUpdate_notFound() {
|
||||
|
@ -86,10 +113,7 @@ class MainViewModelTest {
|
|||
.test()
|
||||
|
||||
viewModel.onResume()
|
||||
sites.assertValues(
|
||||
listOf(),
|
||||
ALL_MOCK_MODELS
|
||||
)
|
||||
sites.assertValues(ALL_MOCK_MODELS)
|
||||
|
||||
val updatedModel2 = MOCK_MODEL_2.copy(
|
||||
name = "Wakanda Forever!!!"
|
||||
|
@ -106,7 +130,7 @@ class MainViewModelTest {
|
|||
@Test fun refreshSite() {
|
||||
viewModel.refreshSite(MOCK_MODEL_3)
|
||||
|
||||
verify(validationManager).scheduleCheck(
|
||||
verify(validationManager).scheduleValidation(
|
||||
site = MOCK_MODEL_3,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
|
@ -120,10 +144,7 @@ class MainViewModelTest {
|
|||
.test()
|
||||
|
||||
viewModel.onResume()
|
||||
sites.assertValues(
|
||||
listOf(),
|
||||
ALL_MOCK_MODELS
|
||||
)
|
||||
sites.assertValues(ALL_MOCK_MODELS)
|
||||
isLoading.assertValues(true, false)
|
||||
|
||||
val modifiedModel = MOCK_MODEL_1.copy(id = 11111)
|
||||
|
@ -132,7 +153,7 @@ class MainViewModelTest {
|
|||
sites.assertNoValues()
|
||||
isLoading.assertValues(true, false)
|
||||
|
||||
verify(validationManager).cancelCheck(modifiedModel)
|
||||
verify(validationManager).cancelScheduledValidation(modifiedModel)
|
||||
verify(notificationManager).cancelStatusNotification(modifiedModel)
|
||||
verify(database.siteDao()).delete(modifiedModel)
|
||||
verify(database.siteSettingsDao()).delete(modifiedModel.settings!!)
|
||||
|
@ -147,10 +168,7 @@ class MainViewModelTest {
|
|||
.test()
|
||||
|
||||
viewModel.onResume()
|
||||
sites.assertValues(
|
||||
listOf(),
|
||||
ALL_MOCK_MODELS
|
||||
)
|
||||
sites.assertValues(ALL_MOCK_MODELS)
|
||||
isLoading.assertValues(true, false)
|
||||
|
||||
val modelsWithout1 = ALL_MOCK_MODELS.toMutableList()
|
||||
|
@ -163,7 +181,7 @@ class MainViewModelTest {
|
|||
isLoading.assertValues(true, false)
|
||||
emptyTextVisibility.assertValues(false, false, false)
|
||||
|
||||
verify(validationManager).cancelCheck(MOCK_MODEL_1)
|
||||
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
|
||||
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
|
||||
verify(database.siteDao()).delete(MOCK_MODEL_1)
|
||||
verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
|
||||
|
|
|
@ -18,6 +18,8 @@ package com.afollestad.nocknock.ui.viewsite
|
|||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import com.afollestad.nocknock.MOCK_MODEL_1
|
||||
import com.afollestad.nocknock.R
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.SiteSettings
|
||||
import com.afollestad.nocknock.data.model.Status.CHECKING
|
||||
|
@ -28,7 +30,8 @@ import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
|
|||
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
|
||||
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
|
||||
import com.afollestad.nocknock.data.model.ValidationResult
|
||||
import com.afollestad.nocknock.engine.validation.ValidationManager
|
||||
import com.afollestad.nocknock.engine.validation.ValidationExecutor
|
||||
import com.afollestad.nocknock.fakeRetryPolicy
|
||||
import com.afollestad.nocknock.mockDatabase
|
||||
import com.afollestad.nocknock.notifications.NockNotificationManager
|
||||
import com.afollestad.nocknock.utilities.livedata.test
|
||||
|
@ -38,9 +41,10 @@ import com.google.common.truth.Truth.assertThat
|
|||
import com.nhaarman.mockitokotlin2.any
|
||||
import com.nhaarman.mockitokotlin2.argumentCaptor
|
||||
import com.nhaarman.mockitokotlin2.doAnswer
|
||||
import com.nhaarman.mockitokotlin2.doReturn
|
||||
import com.nhaarman.mockitokotlin2.mock
|
||||
import com.nhaarman.mockitokotlin2.never
|
||||
import com.nhaarman.mockitokotlin2.verify
|
||||
import com.nhaarman.mockitokotlin2.whenever
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -75,7 +79,7 @@ class ViewSiteViewModelTest {
|
|||
}
|
||||
}
|
||||
private val database = mockDatabase()
|
||||
private val validationManager = mock<ValidationManager>()
|
||||
private val validationManager = mock<ValidationExecutor>()
|
||||
private val notificationManager = mock<NockNotificationManager>()
|
||||
|
||||
@Rule @JvmField val rule = InstantTaskExecutorRule()
|
||||
|
@ -255,247 +259,11 @@ class ViewSiteViewModelTest {
|
|||
.isEqualTo("Two")
|
||||
}
|
||||
|
||||
@Test fun commit_nameError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
name.value = ""
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertValues(R.string.please_enter_name)
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_urlEmptyError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
url.value = ""
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertValues(R.string.please_enter_url)
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_urlFormatError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
url.value = "ftp://www.idk.com"
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertValues(R.string.please_enter_valid_url)
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_networkTimeout_error() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
timeout.value = 0
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertValues(R.string.please_enter_networkTimeout)
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_checkIntervalError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
checkIntervalValue.value = 0
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertValues(R.string.please_enter_check_interval)
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_termSearchError() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
validationMode.value = TERM_SEARCH
|
||||
validationSearchTerm.value = ""
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertValues(R.string.please_enter_search_term)
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_javaScript_error() {
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
|
||||
fillInModel().apply {
|
||||
validationMode.value = JAVASCRIPT
|
||||
validationScript.value = ""
|
||||
}
|
||||
val onDone = mock<() -> Unit>()
|
||||
viewModel.commit(onDone)
|
||||
|
||||
verify(validationManager, never())
|
||||
.scheduleCheck(any(), any(), any(), any())
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertValues(R.string.please_enter_javaScript)
|
||||
|
||||
verify(onDone, never()).invoke()
|
||||
}
|
||||
|
||||
@Test fun commit_success() = runBlocking {
|
||||
whenever(database.retryPolicyDao().forSite(any())).doReturn(listOf(fakeRetryPolicy(1)))
|
||||
|
||||
val isLoading = viewModel.onIsLoading()
|
||||
.test()
|
||||
val onNameError = viewModel.onNameError()
|
||||
.test()
|
||||
val onUrlError = viewModel.onUrlError()
|
||||
.test()
|
||||
val onTimeoutError = viewModel.onTimeoutError()
|
||||
.test()
|
||||
val onSearchTermError = viewModel.onValidationSearchTermError()
|
||||
.test()
|
||||
val onScriptError = viewModel.onValidationScriptError()
|
||||
.test()
|
||||
val onCheckIntervalError = viewModel.onCheckIntervalError()
|
||||
.test()
|
||||
|
||||
fillInModel()
|
||||
val onDone = mock<() -> Unit>()
|
||||
|
@ -506,11 +274,13 @@ class ViewSiteViewModelTest {
|
|||
val siteCaptor = argumentCaptor<Site>()
|
||||
val settingsCaptor = argumentCaptor<SiteSettings>()
|
||||
val resultCaptor = argumentCaptor<ValidationResult>()
|
||||
val retryPolicyCaptor = argumentCaptor<RetryPolicy>()
|
||||
|
||||
isLoading.assertValues(true, false)
|
||||
verify(database.siteDao()).update(siteCaptor.capture())
|
||||
verify(database.siteSettingsDao()).update(settingsCaptor.capture())
|
||||
verify(database.validationResultsDao()).update(resultCaptor.capture())
|
||||
verify(database.retryPolicyDao()).update(retryPolicyCaptor.capture())
|
||||
|
||||
// From fillInModel() below
|
||||
val updatedSettings = MOCK_MODEL_1.settings!!.copy(
|
||||
|
@ -523,31 +293,26 @@ class ViewSiteViewModelTest {
|
|||
val updatedResult = MOCK_MODEL_1.lastResult!!.copy(
|
||||
status = WAITING
|
||||
)
|
||||
val retryPolicy = retryPolicyCaptor.firstValue
|
||||
val updatedModel = MOCK_MODEL_1.copy(
|
||||
name = "Hello There",
|
||||
url = "https://www.hellothere.com",
|
||||
settings = updatedSettings,
|
||||
lastResult = updatedResult
|
||||
lastResult = updatedResult,
|
||||
retryPolicy = retryPolicy
|
||||
)
|
||||
|
||||
assertThat(siteCaptor.firstValue).isEqualTo(updatedModel)
|
||||
assertThat(settingsCaptor.firstValue).isEqualTo(updatedSettings)
|
||||
assertThat(resultCaptor.firstValue).isEqualTo(updatedResult)
|
||||
|
||||
verify(validationManager).scheduleCheck(
|
||||
verify(validationManager).scheduleValidation(
|
||||
site = updatedModel,
|
||||
rightNow = true,
|
||||
cancelPrevious = true,
|
||||
fromFinishingJob = false
|
||||
)
|
||||
|
||||
onNameError.assertNoValues()
|
||||
onUrlError.assertNoValues()
|
||||
onTimeoutError.assertNoValues()
|
||||
onCheckIntervalError.assertNoValues()
|
||||
onSearchTermError.assertNoValues()
|
||||
onScriptError.assertNoValues()
|
||||
|
||||
verify(onDone).invoke()
|
||||
}
|
||||
|
||||
|
@ -562,7 +327,7 @@ class ViewSiteViewModelTest {
|
|||
)
|
||||
|
||||
viewModel.checkNow()
|
||||
verify(validationManager).scheduleCheck(
|
||||
verify(validationManager).scheduleValidation(
|
||||
site = expectedModel,
|
||||
rightNow = true,
|
||||
cancelPrevious = true
|
||||
|
@ -579,7 +344,7 @@ class ViewSiteViewModelTest {
|
|||
viewModel.removeSite(onDone)
|
||||
isLoading.assertValues(true, false)
|
||||
|
||||
verify(validationManager).cancelCheck(MOCK_MODEL_1)
|
||||
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
|
||||
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
|
||||
verify(database.siteDao()).delete(MOCK_MODEL_1)
|
||||
verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
|
||||
|
@ -603,7 +368,7 @@ class ViewSiteViewModelTest {
|
|||
)
|
||||
)
|
||||
|
||||
verify(validationManager).cancelCheck(MOCK_MODEL_1)
|
||||
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
|
||||
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
|
||||
verify(database.siteDao()).update(expectedSite)
|
||||
verify(database.siteSettingsDao()).update(expectedSite.settings!!)
|
||||
|
@ -619,5 +384,12 @@ class ViewSiteViewModelTest {
|
|||
validationScript.value = "throw 'Oh no!'"
|
||||
checkIntervalValue.value = 24
|
||||
checkIntervalUnit.value = 60000
|
||||
tags.value = "one,two"
|
||||
retryPolicyTimes.value = 5
|
||||
retryPolicyMinutes.value = 5
|
||||
headers.value = listOf(
|
||||
Header(2L, 1L, key = "Content-Type", value = "text/html"),
|
||||
Header(3L, 1L, key = "User-Agent", value = "NockNock")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
BIN
art/showcase5.png
Normal file
After Width: | Height: | Size: 528 KiB |
Before Width: | Height: | Size: 678 KiB |
|
@ -15,6 +15,7 @@ buildscript {
|
|||
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin
|
||||
classpath 'com.github.ben-manes:gradle-versions-plugin:' + versions.versionPlugin
|
||||
classpath 'io.fabric.tools:gradle:' + versions.fabricPlugin
|
||||
classpath 'com.google.gms:google-services:' + versions.googleServices
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -22,7 +23,6 @@ allprojects {
|
|||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
maven { url "https://dl.bintray.com/drummer-aidan/maven" }
|
||||
maven { url "https://jitpack.io" }
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,10 @@ android {
|
|||
versionName versions.publishVersion
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
exclude 'META-INF/atomicfu.kotlin_module'
|
||||
}
|
||||
|
||||
// For Mozilla Rhino
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
|
@ -30,6 +34,7 @@ dependencies {
|
|||
implementation 'org.mozilla:rhino:' + versions.rhino
|
||||
|
||||
api 'com.afollestad:rxkprefs:' + versions.rxkPrefs
|
||||
api "io.reactivex.rxjava2:rxjava:" + versions.rxJava
|
||||
|
||||
testImplementation 'junit:junit:' + versions.junit
|
||||
testImplementation 'com.google.truth:truth:' + versions.truth
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Designed and developed by Aidan Follestad (@afollestad)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.afollestad.nocknock.utilities.ext
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
fun String.toUri() = Uri.parse(this)!!
|
||||
|
||||
fun String?.isNotNullOrEmpty(): Boolean {
|
||||
if (this == null || this == "null") {
|
||||
return false
|
||||
}
|
||||
return !isNullOrEmpty()
|
||||
}
|
|
@ -21,7 +21,12 @@ import android.widget.EditText
|
|||
import androidx.annotation.IntRange
|
||||
import kotlin.math.min
|
||||
|
||||
fun EditText.setTextAndMaintainSelection(text: CharSequence) {
|
||||
fun EditText.setTextAndMaintainSelection(text: CharSequence?) {
|
||||
if (text == null) {
|
||||
setText("")
|
||||
return
|
||||
}
|
||||
|
||||
val formerStart = min(selectionStart, text.length)
|
||||
val formerEnd = min(selectionEnd, text.length)
|
||||
setText(text)
|
||||
|
|
|
@ -30,6 +30,8 @@ interface CanNotifyModel : Serializable {
|
|||
fun notifyName(): String
|
||||
|
||||
fun notifyTag(): String
|
||||
|
||||
fun notifyDescription(): String?
|
||||
}
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
|
|
|
@ -20,6 +20,7 @@ import android.app.PendingIntent
|
|||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat.BigTextStyle
|
||||
import androidx.core.app.NotificationCompat.DEFAULT_VIBRATE
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
|
@ -56,6 +57,10 @@ class RealNotificationProvider(
|
|||
.setLargeIcon(largeIcon)
|
||||
.setAutoCancel(true)
|
||||
.setDefaults(DEFAULT_VIBRATE)
|
||||
.setStyle(
|
||||
BigTextStyle()
|
||||
.bigText(content)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,10 @@ android {
|
|||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
exclude 'META-INF/atomicfu.kotlin_module'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
<application>
|
||||
<uses-library
|
||||
android:name="android.test.runner"
|
||||
android:name="androidx.test.runner"
|
||||
android:required="false"/>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -21,6 +21,8 @@ import android.content.Context
|
|||
import androidx.room.Room.inMemoryDatabaseBuilder
|
||||
import androidx.test.core.app.ApplicationProvider.getApplicationContext
|
||||
import androidx.test.runner.AndroidJUnit4
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.SiteSettings
|
||||
import com.afollestad.nocknock.data.model.Status.ERROR
|
||||
|
@ -46,6 +48,7 @@ class AppDatabaseTest() {
|
|||
private lateinit var settingsDao: SiteSettingsDao
|
||||
private lateinit var resultsDao: ValidationResultsDao
|
||||
private lateinit var retryDao: RetryPolicyDao
|
||||
private lateinit var headerDao: HeaderDao
|
||||
|
||||
@Before fun setup() {
|
||||
val context = getApplicationContext<Context>()
|
||||
|
@ -54,13 +57,12 @@ class AppDatabaseTest() {
|
|||
settingsDao = db.siteSettingsDao()
|
||||
resultsDao = db.validationResultsDao()
|
||||
retryDao = db.retryPolicyDao()
|
||||
headerDao = db.headerDao()
|
||||
}
|
||||
|
||||
@After
|
||||
@Throws(IOException::class)
|
||||
fun destroy() {
|
||||
db.close()
|
||||
}
|
||||
fun destroy() = db.close()
|
||||
|
||||
// SiteDao
|
||||
|
||||
|
@ -68,9 +70,11 @@ class AppDatabaseTest() {
|
|||
val model1 = Site(
|
||||
name = "Test 1",
|
||||
url = "https://test1.com",
|
||||
tags = "",
|
||||
settings = null,
|
||||
lastResult = null,
|
||||
retryPolicy = null
|
||||
retryPolicy = null,
|
||||
headers = emptyList()
|
||||
)
|
||||
val newId1 = sitesDao.insert(model1)
|
||||
assertThat(newId1).isGreaterThan(0)
|
||||
|
@ -78,9 +82,11 @@ class AppDatabaseTest() {
|
|||
val model2 = Site(
|
||||
name = "Test 2",
|
||||
url = "https://test2.com",
|
||||
tags = "",
|
||||
settings = null,
|
||||
lastResult = null,
|
||||
retryPolicy = null
|
||||
retryPolicy = null,
|
||||
headers = emptyList()
|
||||
)
|
||||
val newId2 = sitesDao.insert(model2)
|
||||
assertThat(newId2).isGreaterThan(newId1)
|
||||
|
@ -95,9 +101,11 @@ class AppDatabaseTest() {
|
|||
val model = Site(
|
||||
name = "Test",
|
||||
url = "https://test.com",
|
||||
tags = "",
|
||||
settings = null,
|
||||
lastResult = null,
|
||||
retryPolicy = null
|
||||
retryPolicy = null,
|
||||
headers = emptyList()
|
||||
)
|
||||
val newId = sitesDao.insert(model)
|
||||
assertThat(newId).isGreaterThan(0)
|
||||
|
@ -110,9 +118,11 @@ class AppDatabaseTest() {
|
|||
val initialModel = Site(
|
||||
name = "Test 1",
|
||||
url = "https://test1.com",
|
||||
tags = "",
|
||||
settings = null,
|
||||
lastResult = null,
|
||||
retryPolicy = null
|
||||
retryPolicy = null,
|
||||
headers = emptyList()
|
||||
)
|
||||
val newId = sitesDao.insert(initialModel)
|
||||
assertThat(newId).isGreaterThan(0)
|
||||
|
@ -134,9 +144,11 @@ class AppDatabaseTest() {
|
|||
val model1 = Site(
|
||||
name = "Test 1",
|
||||
url = "https://test1.com",
|
||||
tags = "",
|
||||
settings = null,
|
||||
lastResult = null,
|
||||
retryPolicy = null
|
||||
retryPolicy = null,
|
||||
headers = emptyList()
|
||||
)
|
||||
val newId1 = sitesDao.insert(model1)
|
||||
assertThat(newId1).isGreaterThan(0)
|
||||
|
@ -144,9 +156,11 @@ class AppDatabaseTest() {
|
|||
val model2 = Site(
|
||||
name = "Test 2",
|
||||
url = "https://test2.com",
|
||||
tags = "",
|
||||
settings = null,
|
||||
lastResult = null,
|
||||
retryPolicy = null
|
||||
retryPolicy = null,
|
||||
headers = emptyList()
|
||||
)
|
||||
val newId2 = sitesDao.insert(model2)
|
||||
assertThat(newId2).isGreaterThan(newId1)
|
||||
|
@ -167,7 +181,8 @@ class AppDatabaseTest() {
|
|||
validationMode = STATUS_CODE,
|
||||
validationArgs = null,
|
||||
disabled = false,
|
||||
networkTimeout = 10000
|
||||
networkTimeout = 10000,
|
||||
certificate = null
|
||||
)
|
||||
val newId = settingsDao.insert(model)
|
||||
assertThat(newId).isEqualTo(1)
|
||||
|
@ -185,7 +200,8 @@ class AppDatabaseTest() {
|
|||
validationMode = STATUS_CODE,
|
||||
validationArgs = null,
|
||||
disabled = false,
|
||||
networkTimeout = 10000
|
||||
networkTimeout = 10000,
|
||||
certificate = null
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -213,7 +229,8 @@ class AppDatabaseTest() {
|
|||
validationMode = STATUS_CODE,
|
||||
validationArgs = null,
|
||||
disabled = false,
|
||||
networkTimeout = 10000
|
||||
networkTimeout = 10000,
|
||||
certificate = null
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -291,7 +308,7 @@ class AppDatabaseTest() {
|
|||
val newId = retryDao.insert(model)
|
||||
assertThat(newId).isEqualTo(1)
|
||||
|
||||
val finalModel = resultsDao.forSite(newId)
|
||||
val finalModel = retryDao.forSite(newId)
|
||||
.single()
|
||||
assertThat(finalModel).isEqualTo(model.copy(siteId = newId))
|
||||
}
|
||||
|
@ -333,6 +350,78 @@ class AppDatabaseTest() {
|
|||
assertThat(retryDao.forSite(1)).isEmpty()
|
||||
}
|
||||
|
||||
// HeaderDao
|
||||
|
||||
@Test fun headers_insert_and_forSite() {
|
||||
val models = listOf(
|
||||
Header(
|
||||
siteId = 1,
|
||||
key = "Name",
|
||||
value = "Aidan"
|
||||
),
|
||||
Header(
|
||||
siteId = 1,
|
||||
key = "Born",
|
||||
value = "1995"
|
||||
)
|
||||
)
|
||||
val newIds = headerDao.insert(models)
|
||||
assertThat(newIds.first()).isEqualTo(1)
|
||||
assertThat(newIds.last()).isEqualTo(2)
|
||||
|
||||
val finalModels = headerDao.forSite(1)
|
||||
assertThat(finalModels.first()).isEqualTo(models.first().copy(id = 1))
|
||||
assertThat(finalModels.last()).isEqualTo(models.last().copy(id = 2))
|
||||
}
|
||||
|
||||
@Test fun headers_update() {
|
||||
val models = listOf(
|
||||
Header(
|
||||
siteId = 1,
|
||||
key = "Name",
|
||||
value = "Aidan"
|
||||
),
|
||||
Header(
|
||||
siteId = 1,
|
||||
key = "Born",
|
||||
value = "1995"
|
||||
)
|
||||
)
|
||||
headerDao.insert(models)
|
||||
|
||||
val insertedModel = headerDao.forSite(1)
|
||||
.last()
|
||||
val updatedModel = insertedModel.copy(
|
||||
key = "Test",
|
||||
value = "Hello"
|
||||
)
|
||||
assertThat(headerDao.update(updatedModel)).isEqualTo(1)
|
||||
|
||||
val finalModels = headerDao.forSite(1)
|
||||
assertThat(finalModels.first()).isEqualTo(models.first().copy(id = 1))
|
||||
assertThat(finalModels.last()).isEqualTo(updatedModel)
|
||||
}
|
||||
|
||||
@Test fun headers_delete() {
|
||||
val models = listOf(
|
||||
Header(
|
||||
siteId = 1,
|
||||
key = "Name",
|
||||
value = "Aidan"
|
||||
),
|
||||
Header(
|
||||
siteId = 1,
|
||||
key = "Born",
|
||||
value = "1995"
|
||||
)
|
||||
)
|
||||
headerDao.insert(models)
|
||||
|
||||
val insertedModels = headerDao.forSite(1)
|
||||
headerDao.delete(insertedModels)
|
||||
assertThat(headerDao.forSite(1)).isEmpty()
|
||||
}
|
||||
|
||||
// Extension Methods
|
||||
|
||||
@Test fun extension_put_and_allSites() {
|
||||
|
@ -342,9 +431,30 @@ class AppDatabaseTest() {
|
|||
|
||||
val allSites = db.allSites()
|
||||
assertThat(allSites.size).isEqualTo(3)
|
||||
assertThat(allSites[0]).isEqualTo(MOCK_MODEL_1)
|
||||
assertThat(allSites[1]).isEqualTo(MOCK_MODEL_2)
|
||||
assertThat(allSites[2]).isEqualTo(MOCK_MODEL_3)
|
||||
assertThat(allSites[0]).isEqualTo(
|
||||
MOCK_MODEL_1.copy(
|
||||
headers = listOf(
|
||||
MOCK_MODEL_1.headers.first().copy(id = 1),
|
||||
MOCK_MODEL_1.headers.last().copy(id = 2)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(allSites[1]).isEqualTo(
|
||||
MOCK_MODEL_2.copy(
|
||||
headers = listOf(
|
||||
MOCK_MODEL_2.headers.first().copy(id = 3),
|
||||
MOCK_MODEL_2.headers.last().copy(id = 4)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(allSites[2]).isEqualTo(
|
||||
MOCK_MODEL_3.copy(
|
||||
headers = listOf(
|
||||
MOCK_MODEL_3.headers.first().copy(id = 5),
|
||||
MOCK_MODEL_3.headers.last().copy(id = 6)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun extension_put_getSite() {
|
||||
|
@ -379,12 +489,25 @@ class AppDatabaseTest() {
|
|||
count = 4,
|
||||
minutes = 8
|
||||
)
|
||||
val updatedHeaders = listOf(
|
||||
modelToUpdate.headers.first().copy(
|
||||
id = 7,
|
||||
key = "One",
|
||||
value = "Hello"
|
||||
),
|
||||
modelToUpdate.headers.last().copy(
|
||||
id = 8,
|
||||
key = "Two",
|
||||
value = "Hey"
|
||||
)
|
||||
)
|
||||
val updatedModel = modelToUpdate.copy(
|
||||
name = "Oijrfouhef",
|
||||
url = "https://iojfdfsdk.io",
|
||||
settings = updatedSettings,
|
||||
lastResult = updatedValidationResult,
|
||||
retryPolicy = updatedRetryPolicy
|
||||
retryPolicy = updatedRetryPolicy,
|
||||
headers = updatedHeaders
|
||||
)
|
||||
|
||||
db.updateSite(updatedModel)
|
||||
|
@ -393,6 +516,8 @@ class AppDatabaseTest() {
|
|||
assertThat(finalSite.settings).isEqualTo(updatedSettings)
|
||||
assertThat(finalSite.lastResult).isEqualTo(updatedValidationResult)
|
||||
assertThat(finalSite.retryPolicy).isEqualTo(updatedRetryPolicy)
|
||||
assertThat(finalSite.headers.first()).isEqualTo(updatedHeaders.first())
|
||||
assertThat(finalSite.headers.last()).isEqualTo(updatedHeaders.last())
|
||||
assertThat(finalSite).isEqualTo(updatedModel)
|
||||
}
|
||||
|
||||
|
@ -402,7 +527,7 @@ class AppDatabaseTest() {
|
|||
db.putSite(MOCK_MODEL_3)
|
||||
val allSites = db.allSites()
|
||||
|
||||
db.deleteSite(MOCK_MODEL_2)
|
||||
db.deleteSite(allSites[1])
|
||||
|
||||
val remainingSettings = settingsDao.all()
|
||||
assertThat(remainingSettings.size).isEqualTo(2)
|
||||
|
@ -418,5 +543,12 @@ class AppDatabaseTest() {
|
|||
assertThat(remainingRetryPolicies.size).isEqualTo(2)
|
||||
assertThat(remainingRetryPolicies[0]).isEqualTo(allSites[0].retryPolicy!!)
|
||||
assertThat(remainingRetryPolicies[1]).isEqualTo(allSites[2].retryPolicy!!)
|
||||
|
||||
val remainingHeaders = headerDao.all()
|
||||
assertThat(remainingHeaders.size).isEqualTo(4)
|
||||
assertThat(remainingHeaders[0]).isEqualTo(allSites[0].headers.first())
|
||||
assertThat(remainingHeaders[1]).isEqualTo(allSites[0].headers.last())
|
||||
assertThat(remainingHeaders[2]).isEqualTo(allSites[2].headers.first())
|
||||
assertThat(remainingHeaders[3]).isEqualTo(allSites[2].headers.last())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@
|
|||
*/
|
||||
package com.afollestad.nocknock.data
|
||||
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.SiteSettings
|
||||
import com.afollestad.nocknock.data.model.Status
|
||||
|
@ -33,7 +35,8 @@ fun fakeSettingsModel(
|
|||
validationMode = validationMode,
|
||||
validationArgs = null,
|
||||
disabled = false,
|
||||
networkTimeout = 10000
|
||||
networkTimeout = 10000,
|
||||
certificate = null
|
||||
)
|
||||
|
||||
fun fakeResultModel(
|
||||
|
@ -57,13 +60,20 @@ fun fakeRetryPolicy(
|
|||
minutes = minutes
|
||||
)
|
||||
|
||||
fun fakeHeaders(siteId: Long) = listOf(
|
||||
Header(siteId = siteId, key = "Content-Type", value = "text/html"),
|
||||
Header(siteId = siteId, key = "User-Agent", value = "NockNock")
|
||||
)
|
||||
|
||||
fun fakeModel(id: Long) = Site(
|
||||
id = id,
|
||||
name = "Test",
|
||||
url = "https://test.com",
|
||||
tags = "",
|
||||
settings = fakeSettingsModel(id),
|
||||
lastResult = fakeResultModel(id),
|
||||
retryPolicy = fakeRetryPolicy(id)
|
||||
retryPolicy = fakeRetryPolicy(id),
|
||||
headers = fakeHeaders(id)
|
||||
)
|
||||
|
||||
val MOCK_MODEL_1 = fakeModel(1)
|
||||
|
|
|
@ -19,6 +19,8 @@ import androidx.room.Database
|
|||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.afollestad.nocknock.data.model.Converters
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.SiteSettings
|
||||
import com.afollestad.nocknock.data.model.ValidationResult
|
||||
|
@ -26,12 +28,13 @@ import com.afollestad.nocknock.data.model.ValidationResult
|
|||
/** @author Aidan Follestad (@afollestad) */
|
||||
@Database(
|
||||
entities = [
|
||||
Header::class,
|
||||
RetryPolicy::class,
|
||||
ValidationResult::class,
|
||||
SiteSettings::class,
|
||||
Site::class
|
||||
],
|
||||
version = 2,
|
||||
version = 5,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
@ -44,6 +47,8 @@ abstract class AppDatabase : RoomDatabase() {
|
|||
abstract fun validationResultsDao(): ValidationResultsDao
|
||||
|
||||
abstract fun retryPolicyDao(): RetryPolicyDao
|
||||
|
||||
abstract fun headerDao(): HeaderDao
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -60,10 +65,12 @@ fun AppDatabase.allSites(): List<Site> {
|
|||
.singleOrNull()
|
||||
val retryPolicy = retryPolicyDao().forSite(it.id)
|
||||
.singleOrNull()
|
||||
val headers = headerDao().forSite(it.id)
|
||||
return@map it.copy(
|
||||
settings = settings,
|
||||
lastResult = lastResult,
|
||||
retryPolicy = retryPolicy
|
||||
retryPolicy = retryPolicy,
|
||||
headers = headers
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -82,10 +89,12 @@ fun AppDatabase.getSite(id: Long): Site? {
|
|||
.singleOrNull()
|
||||
val retryPolicy = retryPolicyDao().forSite(id)
|
||||
.singleOrNull()
|
||||
val headers = headerDao().forSite(id)
|
||||
return result.copy(
|
||||
settings = settings,
|
||||
lastResult = lastResult,
|
||||
retryPolicy = retryPolicy
|
||||
retryPolicy = retryPolicy,
|
||||
headers = headers
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -100,14 +109,19 @@ fun AppDatabase.putSite(site: Site): Site {
|
|||
val settingsWithSiteId = settings.copy(siteId = newId)
|
||||
val lastResultWithSiteId = site.lastResult?.copy(siteId = newId)
|
||||
val retryPolicyWithSiteId = site.retryPolicy?.copy(siteId = newId)
|
||||
siteSettingsDao().insert(settingsWithSiteId)
|
||||
val headersWithSiteId = site.headers.map { it.copy(siteId = newId) }
|
||||
|
||||
siteSettingsDao().insert(settingsWithSiteId)
|
||||
lastResultWithSiteId?.let { validationResultsDao().insert(it) }
|
||||
retryPolicyWithSiteId?.let { retryPolicyDao().insert(it) }
|
||||
headerDao().insert(headersWithSiteId)
|
||||
|
||||
return site.copy(
|
||||
id = newId,
|
||||
settings = settingsWithSiteId
|
||||
settings = settingsWithSiteId,
|
||||
lastResult = lastResultWithSiteId,
|
||||
retryPolicy = retryPolicyWithSiteId,
|
||||
headers = headersWithSiteId
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -151,6 +165,13 @@ fun AppDatabase.updateSite(site: Site) {
|
|||
retryPolicyDao().insert(retryPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe existing headers
|
||||
headerDao().delete(headerDao().forSite(site.id))
|
||||
// Then add ones that still exist
|
||||
site.headers.forEach { header ->
|
||||
headerDao().insert(header.copy(id = 0, siteId = site.id))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -162,5 +183,9 @@ fun AppDatabase.deleteSite(site: Site) {
|
|||
site.settings?.let { siteSettingsDao().delete(it) }
|
||||
site.lastResult?.let { validationResultsDao().delete(it) }
|
||||
site.retryPolicy?.let { retryPolicyDao().delete(it) }
|
||||
if (site.headers.any { it.id == 0L }) {
|
||||
throw IllegalStateException("Cannot delete header with ID = 0.")
|
||||
}
|
||||
headerDao().delete(site.headers)
|
||||
siteDao().delete(site)
|
||||
}
|
||||
|
|
|
@ -27,7 +27,45 @@ class Database1to2Migration : Migration(1, 2) {
|
|||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"CREATE TABLE `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes INTEGER NOT NULL)"
|
||||
"CREATE TABLE IF NOT EXISTS `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes INTEGER NOT NULL, lastTryTimestamp INTEGER NOT NULL, triesLeft INTEGER NOT NULL)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the database from version 2 to 3.
|
||||
*
|
||||
* @author Aidan Follestad (@afollestad)
|
||||
*/
|
||||
class Database2to3Migration : Migration(2, 3) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE `sites` ADD COLUMN tags TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the database from version 3 to 4.
|
||||
*
|
||||
* @author Aidan Follestad (@afollestad)
|
||||
*/
|
||||
class Database3to4Migration : Migration(3, 4) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `headers` (id INTEGER PRIMARY KEY NOT NULL, siteId INTEGER NOT NULL, `key` TEXT NOT NULL, value TEXT NOT NULL)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the database from version 4 to 5.
|
||||
*
|
||||
* @author Aidan Follestad (@afollestad)
|
||||
*/
|
||||
class Database4to5Migration : Migration(4, 5) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE `site_settings` ADD COLUMN certificate TEXT")
|
||||
}
|
||||
}
|
||||
|
|
47
data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt
Normal file
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Designed and developed by Aidan Follestad (@afollestad)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.afollestad.nocknock.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy.FAIL
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.afollestad.nocknock.data.model.Header
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
@Dao
|
||||
interface HeaderDao {
|
||||
|
||||
@Query("SELECT * FROM headers ORDER BY siteId ASC")
|
||||
fun all(): List<Header>
|
||||
|
||||
@Query("SELECT * FROM headers WHERE siteId = :siteId")
|
||||
fun forSite(siteId: Long): List<Header>
|
||||
|
||||
@Insert(onConflict = FAIL)
|
||||
fun insert(headers: Header): Long
|
||||
|
||||
@Insert(onConflict = FAIL)
|
||||
fun insert(headers: List<Header>): List<Long>
|
||||
|
||||
@Update(onConflict = FAIL)
|
||||
fun update(header: Header): Int
|
||||
|
||||
@Delete
|
||||
fun delete(headers: List<Header>): Int
|
||||
}
|
|
@ -21,6 +21,7 @@ import androidx.room.Insert
|
|||
import androidx.room.OnConflictStrategy.FAIL
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
@Dao
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Designed and developed by Aidan Follestad (@afollestad)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.afollestad.nocknock.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Represents an HTTP header that is sent with a site's validation attempts.
|
||||
*
|
||||
* @author Aidan Follestad (@afollestad)
|
||||
*/
|
||||
@Entity(tableName = "headers")
|
||||
data class Header(
|
||||
/** The header's unique datrabase ID. */
|
||||
@PrimaryKey(autoGenerate = true) var id: Long = 0,
|
||||
/** The [Site] this header belong to. */
|
||||
var siteId: Long = 0,
|
||||
/** The header key/name. */
|
||||
var key: String = "",
|
||||
/** The header value. */
|
||||
var value: String = ""
|
||||
) : Serializable {
|
||||
|
||||
constructor() : this(0, 0, "", "")
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.afollestad.nocknock.data
|
||||
package com.afollestad.nocknock.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
@ -57,6 +57,14 @@ data class RetryPolicy(
|
|||
return -1
|
||||
}
|
||||
val timesPerMinute = count.toFloat() / minutes.toFloat()
|
||||
return MINUTE / timesPerMinute.toInt()
|
||||
return MINUTE / timesPerMinute.toSafeInt()
|
||||
}
|
||||
|
||||
private fun Float.toSafeInt(): Int {
|
||||
val intValue = toInt()
|
||||
if (intValue == 0) {
|
||||
return 1
|
||||
}
|
||||
return intValue
|
||||
}
|
||||
}
|
|
@ -18,7 +18,6 @@ package com.afollestad.nocknock.data.model
|
|||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import com.afollestad.nocknock.data.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Status.WAITING
|
||||
import com.afollestad.nocknock.utilities.ext.timeString
|
||||
import com.afollestad.nocknock.utilities.providers.CanNotifyModel
|
||||
|
@ -32,17 +31,21 @@ data class Site(
|
|||
@PrimaryKey(autoGenerate = true) var id: Long = 0,
|
||||
/** The site's user-given name. */
|
||||
var name: String,
|
||||
/** The URl at which validation attempts are made to. */
|
||||
/** The URL at which validation attempts are made to. */
|
||||
var url: String,
|
||||
/** Comma separated tags for this site. */
|
||||
var tags: String,
|
||||
/** Settings for the site. */
|
||||
@Ignore var settings: SiteSettings?,
|
||||
/** The last validation attempt result for the site, if any. */
|
||||
@Ignore var lastResult: ValidationResult?,
|
||||
/** The site's retry policy, if any. */
|
||||
@Ignore var retryPolicy: RetryPolicy?
|
||||
@Ignore var retryPolicy: RetryPolicy?,
|
||||
/** Request headers sent with this site's validation attempts. */
|
||||
@Ignore var headers: List<Header>
|
||||
) : CanNotifyModel {
|
||||
|
||||
constructor() : this(0, "", "", null, null, null)
|
||||
constructor() : this(0, "", "", "", null, null, null, emptyList())
|
||||
|
||||
override fun notifyId(): Int = id.toInt()
|
||||
|
||||
|
@ -50,6 +53,8 @@ data class Site(
|
|||
|
||||
override fun notifyTag(): String = url
|
||||
|
||||
override fun notifyDescription() = lastResult?.reason
|
||||
|
||||
fun intervalText(): String {
|
||||
requireNotNull(settings) { "Settings not queried." }
|
||||
val lastCheck = lastResult?.timestampMs ?: -1
|
||||
|
|
|
@ -40,8 +40,10 @@ data class SiteSettings(
|
|||
/** Whether or not the [Site] is enabled for automatic periodic checks. */
|
||||
var disabled: Boolean,
|
||||
/** The network response timeout for validation attempts. */
|
||||
var networkTimeout: Int
|
||||
var networkTimeout: Int,
|
||||
/** The Uri to a self signed certificate. */
|
||||
var certificate: String?
|
||||
) : Serializable {
|
||||
|
||||
constructor() : this(0, 0, STATUS_CODE, null, false, 0)
|
||||
constructor() : this(0, 0, STATUS_CODE, null, false, 0, null)
|
||||
}
|
||||
|
|
|
@ -3,52 +3,56 @@ ext.versions = [
|
|||
minSdk : 21,
|
||||
compileSdk : 28,
|
||||
buildTools : '28.0.3',
|
||||
publishVersion : '0.8.2b',
|
||||
publishVersionCode : 33,
|
||||
publishVersion : '0.8.8',
|
||||
publishVersionCode : 46,
|
||||
|
||||
// Plugins
|
||||
gradlePlugin : '3.2.1',
|
||||
spotlessPlugin : '3.17.0',
|
||||
versionPlugin : '0.20.0',
|
||||
gradlePlugin : '3.4.0',
|
||||
spotlessPlugin : '3.22.0',
|
||||
versionPlugin : '0.21.0',
|
||||
googleServices : '4.2.0',
|
||||
fabricPlugin : '1.+',
|
||||
|
||||
// Misc
|
||||
okHttp : '3.12.1',
|
||||
okHttp : '3.14.1',
|
||||
rhino : '1.7.10',
|
||||
|
||||
// Kotlin
|
||||
kotlin : '1.3.11',
|
||||
coroutines : '1.1.0',
|
||||
kotlin : '1.3.30',
|
||||
coroutines : '1.2.0',
|
||||
koin : '1.0.2',
|
||||
|
||||
// Google/AndroidX
|
||||
androidxAnnotations : '1.0.1',
|
||||
androidxAnnotations : '1.0.2',
|
||||
androidxCore : '1.0.2',
|
||||
androidxRecyclerView: '1.0.0',
|
||||
androidxBrowser : '1.0.0',
|
||||
googleMaterial : '1.0.0',
|
||||
room : '2.0.0',
|
||||
lifecycle : '2.0.0',
|
||||
firebaseCore : '16.0.8',
|
||||
|
||||
// Rx
|
||||
rxJava : '2.2.8',
|
||||
rxBinding : '3.0.0-alpha1',
|
||||
|
||||
// afollestad
|
||||
materialDialogs : '2.0.0-rc7',
|
||||
rxkPrefs : '1.2.1',
|
||||
materialDialogs : '2.8.1',
|
||||
rxkPrefs : '1.2.5',
|
||||
vvalidator : '0.4.1',
|
||||
|
||||
// Debugging
|
||||
timber : '4.7.1',
|
||||
fabric : '2.9.8@aar',
|
||||
fabric : '2.9.9@aar',
|
||||
|
||||
// Unit testing
|
||||
junit : '4.12',
|
||||
mockito : '2.23.4',
|
||||
mockitoKotlin : '2.0.0-RC1',
|
||||
truth : '0.42',
|
||||
mockito : '2.27.0',
|
||||
mockitoKotlin : '2.1.0',
|
||||
truth : '0.44',
|
||||
|
||||
// UI testing
|
||||
androidxTestRunner : '1.1.1',
|
||||
androidxTest : '1.1.0',
|
||||
archTesting : '2.0.0'
|
||||
archTesting : '2.0.1'
|
||||
]
|
||||
|
|
|
@ -15,14 +15,18 @@
|
|||
*/
|
||||
package com.afollestad.nocknock.engine
|
||||
|
||||
import com.afollestad.nocknock.engine.validation.RealValidationManager
|
||||
import com.afollestad.nocknock.engine.validation.ValidationManager
|
||||
import com.afollestad.nocknock.engine.ssl.RealSslManager
|
||||
import com.afollestad.nocknock.engine.ssl.SslManager
|
||||
import com.afollestad.nocknock.engine.validation.RealValidationExecutor
|
||||
import com.afollestad.nocknock.engine.validation.ValidationExecutor
|
||||
import org.koin.dsl.module.module
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
val engineModule = module {
|
||||
|
||||
single {
|
||||
RealValidationManager(get(), get(), get(), get(), get(), get())
|
||||
} bind ValidationManager::class
|
||||
RealValidationExecutor(get(), get(), get(), get(), get(), get(), get())
|
||||
} bind ValidationExecutor::class
|
||||
|
||||
factory { RealSslManager(get()) } bind SslManager::class
|
||||
}
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Designed and developed by Aidan Follestad (@afollestad)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.afollestad.nocknock.engine.ssl
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CheckResult
|
||||
import com.afollestad.nocknock.utilities.ext.toUri
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.FileInputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import timber.log.Timber.d as log
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
interface SslManager {
|
||||
|
||||
@CheckResult fun clientForCertificate(
|
||||
certUri: String,
|
||||
siteUri: String,
|
||||
client: OkHttpClient
|
||||
): OkHttpClient
|
||||
}
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) **/
|
||||
class RealSslManager(
|
||||
private val app: Application
|
||||
) : SslManager {
|
||||
|
||||
override fun clientForCertificate(
|
||||
certUri: String,
|
||||
siteUri: String,
|
||||
client: OkHttpClient
|
||||
): OkHttpClient {
|
||||
val parsedCertUri = certUri.toUri()
|
||||
val parsedSiteUri = siteUri.toUri()
|
||||
val siteHost = parsedSiteUri.host ?: ""
|
||||
|
||||
log("Loading certificate $certUri for host $siteHost")
|
||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
keyStore.load(null, null)
|
||||
|
||||
val certInputStream = app.openUri(parsedCertUri)
|
||||
val bis = BufferedInputStream(certInputStream)
|
||||
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||
|
||||
while (bis.available() > 0) {
|
||||
val cert = certificateFactory.generateCertificate(bis)
|
||||
keyStore.setCertificateEntry(siteHost, cert)
|
||||
}
|
||||
|
||||
val trustManagerFactory =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
trustManagerFactory.init(keyStore)
|
||||
|
||||
val trustManagers = trustManagerFactory.trustManagers
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustManagers, null)
|
||||
|
||||
val trustManager = trustManagers.first() as X509TrustManager
|
||||
log("Loaded successfully!")
|
||||
return client.newBuilder()
|
||||
.sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
.hostnameVerifier { hostname, _ ->
|
||||
log("Verifying hostname $hostname")
|
||||
hostname == siteHost
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.openUri(uri: Uri) = when (uri.scheme) {
|
||||
"content" -> {
|
||||
contentResolver.openInputStream(uri) ?: throw IllegalStateException(
|
||||
"Unable to open input stream to $uri"
|
||||
)
|
||||
}
|
||||
"file" -> FileInputStream(uri.path)
|
||||
else -> FileInputStream(uri.toString())
|
||||
}
|
|
@ -32,7 +32,7 @@ import timber.log.Timber.d as log
|
|||
/** @author Aidan Follestad (@afollestad) */
|
||||
class BootReceiver : BroadcastReceiver(), KoinComponent {
|
||||
|
||||
private val validationManager by inject<ValidationManager>()
|
||||
private val validationManager by inject<ValidationExecutor>()
|
||||
private val mainDispatcher by inject<CoroutineDispatcher>(name = MAIN_DISPATCHER)
|
||||
private val ioDispatcher by inject<CoroutineDispatcher>(name = IO_DISPATCHER)
|
||||
|
||||
|
@ -48,7 +48,7 @@ class BootReceiver : BroadcastReceiver(), KoinComponent {
|
|||
|
||||
val pendingResult = goAsync()
|
||||
GlobalScope.launch(mainDispatcher) {
|
||||
withContext(ioDispatcher) { validationManager.ensureScheduledChecks() }
|
||||
withContext(ioDispatcher) { validationManager.ensureScheduledValidations() }
|
||||
pendingResult.resultCode = 0
|
||||
pendingResult.finish()
|
||||
}
|
||||
|
|
|
@ -17,21 +17,26 @@ package com.afollestad.nocknock.engine.validation
|
|||
|
||||
import android.app.job.JobScheduler
|
||||
import android.app.job.JobScheduler.RESULT_SUCCESS
|
||||
import android.net.Uri
|
||||
import com.afollestad.nocknock.data.AppDatabase
|
||||
import com.afollestad.nocknock.data.allSites
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.Status.ERROR
|
||||
import com.afollestad.nocknock.data.model.Status.OK
|
||||
import com.afollestad.nocknock.engine.R
|
||||
import com.afollestad.nocknock.engine.ssl.SslManager
|
||||
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID
|
||||
import com.afollestad.nocknock.utilities.ext.isNotNullOrEmpty
|
||||
import com.afollestad.nocknock.utilities.providers.BundleProvider
|
||||
import com.afollestad.nocknock.utilities.providers.JobInfoProvider
|
||||
import com.afollestad.nocknock.utilities.providers.StringProvider
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jetbrains.annotations.TestOnly
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.TimeUnit.MILLISECONDS
|
||||
import kotlin.math.max
|
||||
import timber.log.Timber.d as log
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
|
@ -42,12 +47,14 @@ data class CheckResult(
|
|||
|
||||
typealias ClientTimeoutChanger = (client: OkHttpClient, timeout: Int) -> OkHttpClient
|
||||
|
||||
typealias UriConverter = (String) -> Uri
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
interface ValidationManager {
|
||||
interface ValidationExecutor {
|
||||
|
||||
suspend fun ensureScheduledChecks()
|
||||
suspend fun ensureScheduledValidations()
|
||||
|
||||
fun scheduleCheck(
|
||||
fun scheduleValidation(
|
||||
site: Site,
|
||||
rightNow: Boolean = false,
|
||||
cancelPrevious: Boolean = rightNow,
|
||||
|
@ -55,19 +62,20 @@ interface ValidationManager {
|
|||
overrideDelay: Long = -1
|
||||
)
|
||||
|
||||
fun cancelCheck(site: Site)
|
||||
fun cancelScheduledValidation(site: Site)
|
||||
|
||||
suspend fun performCheck(site: Site): CheckResult
|
||||
suspend fun performValidation(site: Site): CheckResult
|
||||
}
|
||||
|
||||
class RealValidationManager(
|
||||
class RealValidationExecutor(
|
||||
private val jobScheduler: JobScheduler,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val stringProvider: StringProvider,
|
||||
private val bundleProvider: BundleProvider,
|
||||
private val jobInfoProvider: JobInfoProvider,
|
||||
private val database: AppDatabase
|
||||
) : ValidationManager {
|
||||
private val database: AppDatabase,
|
||||
private val sslManager: SslManager
|
||||
) : ValidationExecutor {
|
||||
|
||||
private var clientTimeoutChanger: ClientTimeoutChanger = { client, timeout ->
|
||||
client.newBuilder()
|
||||
|
@ -75,37 +83,37 @@ class RealValidationManager(
|
|||
.build()
|
||||
}
|
||||
|
||||
override suspend fun ensureScheduledChecks() {
|
||||
override suspend fun ensureScheduledValidations() {
|
||||
val sites = database.allSites()
|
||||
if (sites.isEmpty()) {
|
||||
return
|
||||
}
|
||||
log("Ensuring enabled sites have scheduled checks.")
|
||||
log("Ensuring enabled sites have scheduled validations.")
|
||||
sites.filter { it.settings?.disabled != true }
|
||||
.forEach { site ->
|
||||
val existingJob = jobForSite(site)
|
||||
if (existingJob == null) {
|
||||
log("Site ${site.id} does NOT have a scheduled job, running one now.")
|
||||
scheduleCheck(site = site, rightNow = true)
|
||||
scheduleValidation(site = site, rightNow = true)
|
||||
} else {
|
||||
log("Site ${site.id} already has a scheduled job. Nothing to do.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun scheduleCheck(
|
||||
override fun scheduleValidation(
|
||||
site: Site,
|
||||
rightNow: Boolean,
|
||||
cancelPrevious: Boolean,
|
||||
fromFinishingJob: Boolean,
|
||||
overrideDelay: Long
|
||||
) {
|
||||
check(site.id != 0L) { "Cannot schedule checks for jobs with no ID." }
|
||||
check(site.id != 0L) { "Cannot schedule validations for jobs with no ID." }
|
||||
val siteSettings = site.settings
|
||||
requireNotNull(siteSettings) { "Site settings must be populated." }
|
||||
|
||||
if (cancelPrevious) {
|
||||
cancelCheck(site)
|
||||
cancelScheduledValidation(site)
|
||||
} else if (!fromFinishingJob) {
|
||||
val existingJob = jobForSite(site)
|
||||
check(existingJob == null) {
|
||||
|
@ -113,7 +121,7 @@ class RealValidationManager(
|
|||
}
|
||||
}
|
||||
|
||||
log("Requesting a check job for site to be scheduled: $site")
|
||||
log("Requesting a validation job for site to be scheduled: $site")
|
||||
val extras = bundleProvider.createPersistable {
|
||||
putLong(KEY_SITE_ID, site.id)
|
||||
}
|
||||
|
@ -131,43 +139,59 @@ class RealValidationManager(
|
|||
|
||||
val dispatchResult = jobScheduler.schedule(jobInfo)
|
||||
if (dispatchResult != RESULT_SUCCESS) {
|
||||
log("Failed to schedule a check job for site: ${site.id}")
|
||||
log("Failed to schedule a validation job for site: ${site.id}")
|
||||
} else {
|
||||
log("Check job successfully scheduled for site: ${site.id}")
|
||||
log("Validation job successfully scheduled for site: ${site.id}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancelCheck(site: Site) {
|
||||
check(site.id != 0L) { "Cannot cancel scheduled checks for jobs with no ID." }
|
||||
log("Cancelling scheduled checks for site: ${site.id}")
|
||||
override fun cancelScheduledValidation(site: Site) {
|
||||
check(site.id != 0L) { "Cannot cancel scheduled validations for jobs with no ID." }
|
||||
log("Cancelling scheduled validations for site: ${site.id}")
|
||||
jobScheduler.cancel(site.id.toInt())
|
||||
}
|
||||
|
||||
override suspend fun performCheck(site: Site): CheckResult {
|
||||
check(site.id != 0L) { "Cannot schedule checks for jobs with no ID." }
|
||||
override suspend fun performValidation(site: Site): CheckResult {
|
||||
check(site.id != 0L) { "Cannot schedule validations for jobs with no ID." }
|
||||
val siteSettings = site.settings
|
||||
requireNotNull(siteSettings) { "Site settings must be populated." }
|
||||
check(siteSettings.networkTimeout > 0) { "Network timeout not set for site ${site.id}" }
|
||||
log("performCheck(${site.id}) - GET ${site.url}")
|
||||
log("performValidation(${site.id}) - GET ${site.url}")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(site.url)
|
||||
.get()
|
||||
.apply {
|
||||
url(site.url)
|
||||
get()
|
||||
site.headers
|
||||
.filter { header -> header.key.isNotNullOrEmpty() }
|
||||
.forEach { header ->
|
||||
addHeader(header.key, header.value)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
return try {
|
||||
val client = clientTimeoutChanger(okHttpClient, siteSettings.networkTimeout)
|
||||
val timeout = max(siteSettings.networkTimeout, 1)
|
||||
val clientWithTimeout = clientTimeoutChanger(okHttpClient, timeout)
|
||||
val client = if (siteSettings.certificate.isNotNullOrEmpty()) {
|
||||
sslManager.clientForCertificate(
|
||||
certUri = siteSettings.certificate!!,
|
||||
siteUri = site.url,
|
||||
client = clientWithTimeout
|
||||
)
|
||||
} else {
|
||||
clientWithTimeout
|
||||
}
|
||||
val response = client.newCall(request)
|
||||
.execute()
|
||||
|
||||
if (response.isSuccessful || response.code() == 401) {
|
||||
log("performCheck(${site.id}) = Successful")
|
||||
if (response.isSuccessful) {
|
||||
log("performValidation(${site.id}) = Successful")
|
||||
CheckResult(
|
||||
model = site.withStatus(status = OK, reason = null),
|
||||
response = response
|
||||
)
|
||||
} else {
|
||||
log("performCheck(${site.id}) = Failure, HTTP code ${response.code()}")
|
||||
log("performValidation(${site.id}) = Failure, HTTP code ${response.code()}")
|
||||
CheckResult(
|
||||
model = site.withStatus(
|
||||
status = ERROR,
|
||||
|
@ -177,7 +201,7 @@ class RealValidationManager(
|
|||
)
|
||||
}
|
||||
} catch (timeoutEx: SocketTimeoutException) {
|
||||
log("performCheck(${site.id}) = Socket Timeout")
|
||||
log("performValidation(${site.id}) = Socket Timeout")
|
||||
CheckResult(
|
||||
model = site.withStatus(
|
||||
status = ERROR,
|
||||
|
@ -185,7 +209,8 @@ class RealValidationManager(
|
|||
)
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
log("performCheck(${site.id}) = Error: ${ex.message}")
|
||||
ex.printStackTrace()
|
||||
log("performValidation(${site.id}) = Error: ${ex.message}")
|
||||
CheckResult(model = site.withStatus(status = ERROR, reason = ex.message))
|
||||
}
|
||||
}
|
||||
|
@ -194,7 +219,7 @@ class RealValidationManager(
|
|||
jobScheduler.allPendingJobs
|
||||
.firstOrNull { job -> job.id == site.id.toInt() }
|
||||
|
||||
// @TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) {
|
||||
// this.clientTimeoutChanger = changer
|
||||
// }
|
||||
@TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) {
|
||||
this.clientTimeoutChanger = changer
|
||||
}
|
||||
}
|
|
@ -19,8 +19,8 @@ import android.app.job.JobParameters
|
|||
import android.app.job.JobService
|
||||
import android.content.Intent
|
||||
import com.afollestad.nocknock.data.AppDatabase
|
||||
import com.afollestad.nocknock.data.RetryPolicy
|
||||
import com.afollestad.nocknock.data.getSite
|
||||
import com.afollestad.nocknock.data.model.RetryPolicy
|
||||
import com.afollestad.nocknock.data.model.Site
|
||||
import com.afollestad.nocknock.data.model.Status
|
||||
import com.afollestad.nocknock.data.model.Status.CHECKING
|
||||
|
@ -60,7 +60,7 @@ class ValidationJob : JobService() {
|
|||
}
|
||||
|
||||
private val database by inject<AppDatabase>()
|
||||
private val validationManager by inject<ValidationManager>()
|
||||
private val validationManager by inject<ValidationExecutor>()
|
||||
private val notificationManager by inject<NockNotificationManager>()
|
||||
|
||||
override fun onStartJob(params: JobParameters): Boolean {
|
||||
|
@ -80,10 +80,14 @@ class ValidationJob : JobService() {
|
|||
sendBroadcast(Intent(ACTION_JOB_RUNNING).apply { putExtra(KEY_SITE_ID, site.id) })
|
||||
|
||||
log("Checking ${site.name} (${site.url})...")
|
||||
val lastResult = site.lastResult
|
||||
if (lastResult != null) {
|
||||
log("Result of previous attempt: ${lastResult.status}")
|
||||
}
|
||||
|
||||
val jobResult = async(IO) {
|
||||
updateStatus(site, CHECKING)
|
||||
val checkResult = validationManager.performCheck(site)
|
||||
val checkResult = validationManager.performValidation(site)
|
||||
val resultModel = checkResult.model
|
||||
val resultResponse = checkResult.response
|
||||
val result = resultModel.lastResult!!
|
||||
|
@ -139,6 +143,9 @@ class ValidationJob : JobService() {
|
|||
|
||||
if (jobResult.lastResult!!.status == OK) {
|
||||
notificationManager.cancelStatusNotification(jobResult)
|
||||
if (lastResult != null && lastResult.status == ERROR) {
|
||||
notificationManager.postValidationSuccessNotification(jobResult)
|
||||
}
|
||||
} else {
|
||||
val retryPolicy = site.retryPolicy
|
||||
if (retryPolicy != null) {
|
||||
|
@ -153,7 +160,7 @@ class ValidationJob : JobService() {
|
|||
updateTriesLeft(retryPolicy, retryPolicy.triesLeft)
|
||||
|
||||
val interval = retryPolicy.interval()
|
||||
validationManager.scheduleCheck(
|
||||
validationManager.scheduleValidation(
|
||||
site = jobResult,
|
||||
fromFinishingJob = true,
|
||||
overrideDelay = interval
|
||||
|
@ -167,10 +174,10 @@ class ValidationJob : JobService() {
|
|||
}
|
||||
}
|
||||
|
||||
notificationManager.postStatusNotification(jobResult)
|
||||
notificationManager.postValidationErrorNotification(jobResult)
|
||||
}
|
||||
|
||||
validationManager.scheduleCheck(
|
||||
validationManager.scheduleValidation(
|
||||
site = jobResult,
|
||||
fromFinishingJob = true
|
||||
)
|
||||
|
@ -225,6 +232,7 @@ class ValidationJob : JobService() {
|
|||
triesLeft: Int
|
||||
) {
|
||||
retryPolicy.triesLeft = triesLeft
|
||||
retryPolicy.lastTryTimestamp = currentTimeMillis()
|
||||
withContext(IO) {
|
||||
database.retryPolicyDao()
|
||||
.update(retryPolicy)
|
||||
|
|