diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md
new file mode 100644
index 0000000..2d7d09f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/Bug_report.md
@@ -0,0 +1,28 @@
+---
+name: Bug report
+about: Something is crashing or not working as intended
+
+---
+
+*Please consider making a Pull Request if you are capable of doing so.*
+
+**App Version:**
+
+x.x.x
+
+**Affected Device(s):**
+
+Google Pixel 3 XL with Android 9.0
+
+**Describe the Bug:**
+
+A clear description of what is the bug is.
+
+**To Reproduce:**
+1.
+2.
+3.
+
+**Expected Behavior:**
+
+A clear description of what you expected to happen.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md
new file mode 100644
index 0000000..77310ae
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/Feature_request.md
@@ -0,0 +1,15 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+
+---
+
+*Please consider making a Pull Request if you are capable of doing so.*
+
+**Description what you'd like to happen:**
+
+A clear description if the feature or behavior you'd like implemented.
+
+**Describe alternatives you've considered:**
+
+A clear description of any alternative solutions you've considered.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..6307e10
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,8 @@
+### Guidelines
+
+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.**
diff --git a/.gitignore b/.gitignore
index 161128f..454e51a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -180,4 +180,6 @@ gradle-app.setting
.gradletasknamecache
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
-# gradle/wrapper/gradle-wrapper.properties
\ No newline at end of file
+# gradle/wrapper/gradle-wrapper.properties
+
+app/google-services.json
\ No newline at end of file
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index f519041..0000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-nock-nock
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index 96cc43e..0000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml
deleted file mode 100644
index e7bedf3..0000000
--- a/.idea/copyright/profiles_settings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
deleted file mode 100644
index 97626ba..0000000
--- a/.idea/encodings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index fbb6828..50f0406 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,46 +1,49 @@
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
-
\ No newline at end of file
+
diff --git a/.idea/modules.xml b/.idea/modules.xml
index 56aca0f..23f5d23 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -3,7 +3,12 @@
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 70085e5..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-language: android
-jdk: oraclejdk8
-android:
- components:
- - tools
- - platform-tools
- - build-tools-24.0.1
- - android-24
- - extra-android-support
- - extra-android-m2repository
- - extra-google-m2repository
-
- # Additional components
- #- extra-google-google_play_services
- #- addon-google_apis-google-19
-
- # Specify at least one system image, if you need to run emulator(s) during your tests
- #- sys-img-armeabi-v7a-android-19
- #- sys-img-x86-android-17
-
- licenses:
- - '.+'
\ No newline at end of file
diff --git a/NockNock-0.1.3.0.apk b/NockNock-0.1.3.0.apk
deleted file mode 100644
index c4d3367..0000000
Binary files a/NockNock-0.1.3.0.apk and /dev/null differ
diff --git a/README.md b/README.md
index a15f38e..84ee16c 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,8 @@
## Nock Nock
-[](https://travis-ci.org/afollestad/nock-nock)
[](https://www.apache.org/licenses/LICENSE-2.0.html)
-
+
Nock Nock is a simple app which allows you to monitor your websites for maximum uptime.
@@ -11,4 +10,4 @@ The app will automatically knock on the door of your websites (or web servers) o
to make sure they are up and responding successfully. If something is wrong, you get a notification telling you so.
-
\ No newline at end of file
+
diff --git a/app/build.gradle b/app/build.gradle
index 23e53a1..d6b315e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,43 +1,79 @@
+apply from: '../dependencies.gradle'
apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
+apply plugin: 'kotlin-android-extensions'
android {
- compileSdkVersion 24
- buildToolsVersion "24.0.1"
+ compileSdkVersion versions.compileSdk
+ buildToolsVersion versions.buildTools
- defaultConfig {
- applicationId "com.afollestad.nocknock"
- minSdkVersion 21
- targetSdkVersion 24
- versionCode 13
- versionName "0.1.3.0"
+ defaultConfig {
+ applicationId "com.afollestad.nocknock"
+ minSdkVersion versions.minSdk
+ targetSdkVersion versions.compileSdk
+ versionCode versions.publishVersionCode
+ versionName versions.publishVersion
+ }
- lintOptions {
- abortOnError false
- }
- jackOptions {
- enabled true
- }
- }
+ compileOptions {
+ sourceCompatibility 1.8
+ targetCompatibility 1.8
+ }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
- }
- }
+ packagingOptions {
+ exclude 'META-INF/atomicfu.kotlin_module'
+ }
}
dependencies {
- compile 'com.android.support:appcompat-v7:24.2.0'
- compile 'com.android.support:design:24.2.0'
- compile 'com.afollestad.material-dialogs:core:0.9.0.1'
- compile 'com.afollestad.material-dialogs:commons:0.9.0.1'
- compile 'com.afollestad:bridge:3.2.5'
- compile 'com.afollestad:inquiry:3.2.1'
- compile files('libs/rhino-1.7.7.1.jar')
-}
\ No newline at end of file
+ implementation project(':common')
+ implementation project(':engine')
+ implementation project(':data')
+ implementation project(':notifications')
+ implementation project(':viewcomponents')
+
+ // Google/AppCompat
+ implementation 'androidx.appcompat:appcompat:' + versions.androidxCore
+ 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
+
+ // Kotlin
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
+
+ // JOIN
+ implementation 'org.koin:koin-android:' + versions.koin
+ implementation 'org.koin:koin-androidx-scope:' + versions.koin
+ implementation 'org.koin:koin-androidx-viewmodel:' + versions.koin
+
+ // afollestad
+ implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
+
+ // Debugging
+ implementation 'com.jakewharton.timber:timber:' + versions.timber
+ implementation("com.crashlytics.sdk.android:crashlytics:${versions.fabric}") {
+ transitive = true
+ }
+
+ // Testing
+ testImplementation 'junit:junit:' + versions.junit
+ testImplementation 'org.mockito:mockito-core:' + versions.mockito
+ testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin
+ testImplementation 'com.google.truth:truth:' + versions.truth
+ testImplementation 'androidx.arch.core:core-testing:' + versions.archTesting
+
+ // UI testing
+ androidTestImplementation 'androidx.test:runner:' + versions.androidxTestRunner
+ androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner
+}
+
+apply from: '../spotless.gradle'
+apply from: '../mock/mock.gradle'
+
+apply plugin: "io.fabric"
+apply plugin: 'com.google.gms.google-services'
\ No newline at end of file
diff --git a/app/libs/rhino-1.7.7.1.jar b/app/libs/rhino-1.7.7.1.jar
deleted file mode 100644
index a8b9417..0000000
Binary files a/app/libs/rhino-1.7.7.1.jar and /dev/null differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
deleted file mode 100644
index 666743b..0000000
--- a/app/proguard-rules.pro
+++ /dev/null
@@ -1,17 +0,0 @@
-# Add project specific ProGuard rules here.
-# By default, the flags in this file are appended to flags specified
-# in C:\Users\drumm\AppData\Local\Android\sdk/tools/proguard/proguard-android.txt
-# You can edit the include path and order by changing the proguardFiles
-# directive in build.gradle.
-#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
-
-# Add any project specific keep options here:
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d6a9d32..332578e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,60 +3,57 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.afollestad.nocknock">
-
-
-
+
+
+
-
+
-
-
-
+
+
+
-
-
-
+
+
+
-
+
-
+
-
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
-
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/afollestad/nocknock/AppExt.kt b/app/src/main/java/com/afollestad/nocknock/AppExt.kt
new file mode 100644
index 0000000..828e01f
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/AppExt.kt
@@ -0,0 +1,92 @@
+/**
+ * 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
+
+import android.app.Activity
+import android.app.Application
+import android.app.Application.ActivityLifecycleCallbacks
+import android.content.ActivityNotFoundException
+import android.content.Intent
+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
+
+/** @author Aidan Follestad (@afollestad) */
+fun Application.onActivityLifeChange(cb: ActivityLifeChange) {
+ registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
+ override fun onActivitySaveInstanceState(
+ activity: Activity?,
+ outState: Bundle?
+ ) = Unit
+
+ override fun onActivityPaused(activity: Activity) = cb(activity, false)
+
+ override fun onActivityResumed(activity: Activity) = cb(activity, true)
+
+ override fun onActivityStarted(activity: Activity) = Unit
+
+ override fun onActivityDestroyed(activity: Activity) = Unit
+
+ override fun onActivityStopped(activity: Activity) = Unit
+
+ override fun onActivityCreated(
+ activity: Activity?,
+ savedInstanceState: Bundle?
+ ) = Unit
+ })
+}
+
+fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY)
+
+fun Activity.viewUrl(url: String) {
+ val customTabsIntent = CustomTabsIntent.Builder()
+ .apply {
+ setToolbarColor(resolveColor(this@viewUrl, attr = R.attr.colorPrimary))
+ }
+ .build()
+ try {
+ customTabsIntent.launchUrl(this, url.toUri())
+ } catch (_: ActivityNotFoundException) {
+ toast(R.string.install_web_browser)
+ }
+}
+
+fun Activity.viewUrlWithApp(
+ url: String,
+ pkg: String
+) {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data = url.toUri()
+ }
+ val resInfo = packageManager.queryIntentActivities(intent, 0)
+ for (info in resInfo) {
+ if (info.activityInfo.packageName.toLowerCase().contains(pkg) ||
+ info.activityInfo.name.toLowerCase().contains(pkg)
+ ) {
+ startActivity(intent.apply {
+ setPackage(info.activityInfo.packageName)
+ })
+ return
+ }
+ }
+ viewUrl(url)
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt
new file mode 100644
index 0000000..3c29301
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt
@@ -0,0 +1,79 @@
+/**
+ * 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
+
+import android.app.Application
+import com.afollestad.nocknock.BuildConfig.DEBUG
+import com.afollestad.nocknock.engine.engineModule
+import com.afollestad.nocknock.koin.mainModule
+import com.afollestad.nocknock.koin.prefModule
+import com.afollestad.nocknock.koin.viewModelModule
+import com.afollestad.nocknock.logging.FabricTree
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.notifications.notificationsModule
+import com.afollestad.nocknock.utilities.commonModule
+import com.crashlytics.android.Crashlytics
+import io.fabric.sdk.android.Fabric
+import org.koin.android.ext.android.inject
+import org.koin.android.ext.android.startKoin
+import timber.log.Timber
+import timber.log.Timber.DebugTree
+import timber.log.Timber.d as log
+
+/** @author Aidan Follestad (@afollestad) */
+class NockNockApp : Application() {
+
+ private var resumedActivities: Int = 0
+
+ override fun onCreate() {
+ super.onCreate()
+
+ if (DEBUG) {
+ Timber.plant(DebugTree())
+ }
+
+ Timber.plant(FabricTree())
+ Fabric.with(this, Crashlytics())
+
+ val modules = listOf(
+ prefModule,
+ mainModule,
+ engineModule,
+ commonModule,
+ notificationsModule,
+ viewModelModule
+ )
+ startKoin(
+ androidContext = this,
+ modules = modules
+ )
+
+ val nockNotificationManager by inject()
+ onActivityLifeChange { activity, resumed ->
+ if (resumed) {
+ resumedActivities++
+ log("Activity resumed: $activity, resumedActivities = $resumedActivities")
+ } else {
+ resumedActivities--
+ log("Activity paused: $activity, resumedActivities = $resumedActivities")
+ }
+ check(resumedActivities >= 0) { "resumedActivities can't go below 0." }
+ nockNotificationManager.setIsAppOpen(resumedActivities > 0)
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.java b/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.java
deleted file mode 100644
index b4da56b..0000000
--- a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.java
+++ /dev/null
@@ -1,171 +0,0 @@
-package com.afollestad.nocknock.adapter;
-
-import android.support.v7.widget.RecyclerView;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import com.afollestad.nocknock.R;
-import com.afollestad.nocknock.api.ServerModel;
-import com.afollestad.nocknock.api.ServerStatus;
-import com.afollestad.nocknock.util.TimeUtil;
-import com.afollestad.nocknock.views.StatusImageView;
-
-import java.util.ArrayList;
-import java.util.Collections;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class ServerAdapter extends RecyclerView.Adapter {
-
- private final Object LOCK = new Object();
- private ArrayList mServers;
- private ClickListener mListener;
-
- public interface ClickListener {
- void onSiteSelected(int index, ServerModel model, boolean longClick);
- }
-
- public void performClick(int index, boolean longClick) {
- if (mListener != null) {
- mListener.onSiteSelected(index, mServers.get(index), longClick);
- }
- }
-
- public ServerAdapter(ClickListener listener) {
- mListener = listener;
- mServers = new ArrayList<>(2);
- }
-
- public void add(ServerModel model) {
- mServers.add(model);
- notifyItemInserted(mServers.size() - 1);
- }
-
- public void update(int index, ServerModel model) {
- mServers.set(index, model);
- notifyItemChanged(index);
- }
-
- public void update(ServerModel model) {
- synchronized (LOCK) {
- for (int i = 0; i < mServers.size(); i++) {
- if (mServers.get(i).id == model.id) {
- update(i, model);
- break;
- }
- }
- }
- }
-
- public void remove(int index) {
- mServers.remove(index);
- notifyItemRemoved(index);
- }
-
- public void remove(ServerModel model) {
- synchronized (LOCK) {
- for (int i = 0; i < mServers.size(); i++) {
- if (mServers.get(i).id == model.id) {
- remove(i);
- break;
- }
- }
- }
- }
-
- public void set(ServerModel[] models) {
- if (models == null || models.length == 0) {
- mServers.clear();
- return;
- }
- mServers = new ArrayList<>(models.length);
- Collections.addAll(mServers, models);
- notifyDataSetChanged();
- }
-
- public void clear() {
- mServers.clear();
- notifyDataSetChanged();
- }
-
- @Override
- public ServerAdapter.ServerVH onCreateViewHolder(ViewGroup parent, int viewType) {
- final View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_server, parent, false);
- return new ServerVH(v, this);
- }
-
- @Override
- public void onBindViewHolder(ServerAdapter.ServerVH holder, int position) {
- final ServerModel model = mServers.get(position);
-
- holder.textName.setText(model.name);
- holder.textUrl.setText(model.url);
- holder.iconStatus.setStatus(model.status);
-
- switch (model.status) {
- case ServerStatus.OK:
- holder.textStatus.setText(R.string.everything_checks_out);
- break;
- case ServerStatus.WAITING:
- holder.textStatus.setText(R.string.waiting);
- break;
- case ServerStatus.CHECKING:
- holder.textStatus.setText(R.string.checking_status);
- break;
- case ServerStatus.ERROR:
- holder.textStatus.setText(model.reason);
- break;
- }
-
- if (model.checkInterval <= 0) {
- holder.textInterval.setText("");
- } else {
- final long now = System.currentTimeMillis();
- final long nextCheck = model.lastCheck + model.checkInterval;
- final long difference = nextCheck - now;
- holder.textInterval.setText(TimeUtil.str(difference));
- }
- }
-
- @Override
- public int getItemCount() {
- return mServers.size();
- }
-
- public static class ServerVH extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener {
-
- final StatusImageView iconStatus;
- final TextView textName;
- final TextView textInterval;
- final TextView textUrl;
- final TextView textStatus;
- final ServerAdapter adapter;
-
- public ServerVH(View itemView, ServerAdapter adapter) {
- super(itemView);
- iconStatus = (StatusImageView) itemView.findViewById(R.id.iconStatus);
- textName = (TextView) itemView.findViewById(R.id.textName);
- textInterval = (TextView) itemView.findViewById(R.id.textInterval);
- textUrl = (TextView) itemView.findViewById(R.id.textUrl);
- textStatus = (TextView) itemView.findViewById(R.id.textStatus);
- this.adapter = adapter;
-
- itemView.setOnClickListener(this);
- itemView.setOnLongClickListener(this);
- }
-
- @Override
- public void onClick(View view) {
- adapter.performClick(getAdapterPosition(), false);
- }
-
- @Override
- public boolean onLongClick(View view) {
- adapter.performClick(getAdapterPosition(), true);
- return false;
- }
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt
new file mode 100644
index 0000000..e1b034c
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt
@@ -0,0 +1,131 @@
+/**
+ * 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.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil.calculateDiff
+import androidx.recyclerview.widget.RecyclerView
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.Status.WAITING
+import com.afollestad.nocknock.data.model.isPending
+import com.afollestad.nocknock.data.model.textRes
+import com.afollestad.nocknock.utilities.ui.onDebouncedClick
+import kotlinx.android.synthetic.main.list_item_server.view.iconStatus
+import kotlinx.android.synthetic.main.list_item_server.view.textInterval
+import kotlinx.android.synthetic.main.list_item_server.view.textName
+import kotlinx.android.synthetic.main.list_item_server.view.textStatus
+import kotlinx.android.synthetic.main.list_item_server.view.textUrl
+
+typealias Listener = (model: Site, longClick: Boolean) -> Unit
+
+/** @author Aidan Follestad (@afollestad) */
+class SiteViewHolder constructor(
+ itemView: View,
+ private val adapter: SiteAdapter
+) : RecyclerView.ViewHolder(itemView), View.OnLongClickListener {
+
+ init {
+ itemView.onDebouncedClick {
+ adapter.performClick(adapterPosition, false)
+ }
+ itemView.setOnLongClickListener(this)
+ }
+
+ fun bind(model: Site) {
+ requireNotNull(model.settings) { "Settings must be populated." }
+
+ itemView.textName.text = model.name
+ itemView.textUrl.text = model.url
+
+ val lastResult = model.lastResult
+ if (lastResult != null) {
+ itemView.iconStatus.setStatus(lastResult.status)
+ val statusText = lastResult.status.textRes()
+ if (statusText == 0) {
+ itemView.textStatus.text = lastResult.reason
+ } else {
+ itemView.textStatus.setText(statusText)
+ }
+ } else {
+ itemView.iconStatus.setStatus(WAITING)
+ itemView.textStatus.setText(R.string.none)
+ }
+
+ val res = itemView.resources
+ when {
+ model.settings?.disabled == true -> {
+ itemView.textInterval.setText(R.string.checks_disabled)
+ }
+ model.lastResult?.status.isPending() -> {
+ itemView.textInterval.text = res.getString(
+ R.string.next_check_x,
+ res.getString(R.string.now)
+ )
+ }
+ else -> {
+ itemView.textInterval.text = res.getString(
+ R.string.next_check_x,
+ model.intervalText()
+ )
+ }
+ }
+ }
+
+ override fun onLongClick(view: View): Boolean {
+ adapter.performClick(adapterPosition, true)
+ return false
+ }
+}
+
+/** @author Aidan Follestad (@afollestad) */
+class SiteAdapter(private val listener: Listener) : RecyclerView.Adapter() {
+
+ private var models = mutableListOf()
+
+ internal fun performClick(
+ index: Int,
+ longClick: Boolean
+ ) = listener.invoke(models[index], longClick)
+
+ fun set(newModels: List) {
+ val formerModels = this.models
+ this.models = newModels.toMutableList()
+ val diffResult = calculateDiff(SiteDiffCallback(formerModels, this.models))
+ diffResult.dispatchUpdatesTo(this)
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): SiteViewHolder {
+ val v = LayoutInflater.from(parent.context)
+ .inflate(R.layout.list_item_server, parent, false)
+ return SiteViewHolder(v, this)
+ }
+
+ override fun onBindViewHolder(
+ holder: SiteViewHolder,
+ position: Int
+ ) {
+ val model = models[position]
+ holder.bind(model)
+ }
+
+ override fun getItemCount() = models.size
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt
new file mode 100644
index 0000000..de8b7ca
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt
@@ -0,0 +1,40 @@
+/**
+ * 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 androidx.recyclerview.widget.DiffUtil
+import com.afollestad.nocknock.data.model.Site
+
+/** @author Aidan Follestad (@afollestad) */
+class SiteDiffCallback(
+ private val oldItems: List,
+ private val newItems: List
+) : DiffUtil.Callback() {
+
+ override fun getOldListSize() = oldItems.size
+
+ override fun getNewListSize() = newItems.size
+
+ override fun areItemsTheSame(
+ oldItemPosition: Int,
+ newItemPosition: Int
+ ) = oldItems[oldItemPosition].id == newItems[newItemPosition].id
+
+ override fun areContentsTheSame(
+ oldItemPosition: Int,
+ newItemPosition: Int
+ ) = oldItems[oldItemPosition] == newItems[newItemPosition]
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt
new file mode 100644
index 0000000..7bf22e5
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt
@@ -0,0 +1,115 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.adapter
+
+import android.graphics.Color.WHITE
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.OnClickListener
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.adapter.TagAdapter.TagViewHolder
+import kotlinx.android.synthetic.main.list_item_tag.view.chip
+
+typealias TagsListener = (tags: List) -> Unit
+
+/** @author Aidan Follestad (@afollestad) */
+class TagAdapter(
+ private val listener: TagsListener
+) : RecyclerView.Adapter() {
+
+ private val tags = mutableListOf()
+ private val checked = mutableListOf()
+
+ fun set(tags: List) {
+ this.tags.run {
+ clear()
+ addAll(tags)
+ }
+ notifyDataSetChanged()
+ }
+
+ fun toggleChecked(index: Int) {
+ if (checked.contains(index)) {
+ checked.remove(index)
+ } else {
+ checked.add(index)
+ }
+ notifyItemChanged(index)
+ listener.invoke(getCheckedTags())
+ }
+
+ private fun getCheckedTags(): List {
+ return mutableListOf().apply {
+ checked.forEach { index -> add(tags[index]) }
+ }
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): TagViewHolder {
+ val view = LayoutInflater.from(parent.context)
+ .inflate(R.layout.list_item_tag, parent, false)
+ return TagViewHolder(view, this)
+ }
+
+ override fun getItemCount() = tags.size
+
+ override fun onBindViewHolder(
+ holder: TagViewHolder,
+ position: Int
+ ) {
+ holder.bind(tags[position], checked.contains(position))
+ }
+
+ /** @author Aidan Follestad (@afollestad) */
+ class TagViewHolder(
+ itemView: View,
+ private val adapter: TagAdapter
+ ) : ViewHolder(itemView), OnClickListener {
+
+ override fun onClick(v: View) = adapter.toggleChecked(adapterPosition)
+
+ init {
+ itemView.setOnClickListener(this)
+ }
+
+ fun bind(
+ name: String,
+ checked: Boolean
+ ) = itemView.chip.run {
+ text = name
+ setTextColor(
+ if (checked) {
+ WHITE
+ } else {
+ ContextCompat.getColor(itemView.context, R.color.unchecked_chip_text)
+ }
+ )
+ setBackgroundResource(
+ if (checked) {
+ R.drawable.checked_chip_selector
+ } else {
+ R.drawable.unchecked_chip_selector
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/api/ServerModel.java b/app/src/main/java/com/afollestad/nocknock/api/ServerModel.java
deleted file mode 100644
index 1e3b6df..0000000
--- a/app/src/main/java/com/afollestad/nocknock/api/ServerModel.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.afollestad.nocknock.api;
-
-import com.afollestad.inquiry.annotations.Column;
-
-import java.io.Serializable;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class ServerModel implements Serializable {
-
- public ServerModel() {
- }
-
- @Column(name = "_id", primaryKey = true, notNull = true, autoIncrement = true)
- public long id;
- @Column
- public String name;
- @Column
- public String url;
- @Column
- @ServerStatus.Enum
- public int status;
- @Column
- public long checkInterval;
- @Column
- public long lastCheck;
- @Column
- public String reason;
-
- @Column
- @ValidationMode.Enum
- public int validationMode;
- @Column
- public String validationContent;
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/afollestad/nocknock/api/ServerStatus.java b/app/src/main/java/com/afollestad/nocknock/api/ServerStatus.java
deleted file mode 100644
index f7f98fe..0000000
--- a/app/src/main/java/com/afollestad/nocknock/api/ServerStatus.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.afollestad.nocknock.api;
-
-import android.support.annotation.IntDef;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public final class ServerStatus {
-
- public final static int OK = 1;
- public final static int WAITING = 2;
- public final static int CHECKING = 3;
- public final static int ERROR = 4;
-
- @Retention(RetentionPolicy.SOURCE)
- @IntDef({OK, WAITING, CHECKING, ERROR})
- public @interface Enum {}
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/api/ValidationMode.java b/app/src/main/java/com/afollestad/nocknock/api/ValidationMode.java
deleted file mode 100644
index 2606635..0000000
--- a/app/src/main/java/com/afollestad/nocknock/api/ValidationMode.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.afollestad.nocknock.api;
-
-import android.support.annotation.IntDef;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public final class ValidationMode {
-
- public final static int STATUS_CODE = 1;
- public final static int TERM_SEARCH = 2;
- public final static int JAVASCRIPT = 3;
-
- @Retention(RetentionPolicy.SOURCE)
- @IntDef({STATUS_CODE, TERM_SEARCH, JAVASCRIPT})
- public @interface Enum {
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt
new file mode 100644
index 0000000..c5567c5
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt
@@ -0,0 +1,68 @@
+/**
+ * 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.broadcasts
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.lifecycle.Lifecycle.Event.ON_DESTROY
+import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
+import androidx.lifecycle.Lifecycle.Event.ON_RESUME
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.OnLifecycleEvent
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL
+import com.afollestad.nocknock.utilities.providers.IntentProvider
+
+typealias SiteCallback = (Site) -> Unit
+
+/** @author Aidan Follestad (@afollestad) */
+class StatusUpdateIntentReceiver(
+ private val context: Context,
+ private val intentProvider: IntentProvider,
+ private var callback: SiteCallback?
+) : LifecycleObserver {
+
+ internal val intentReceiver = object : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent
+ ) {
+ if (intent.action == ACTION_STATUS_UPDATE) {
+ val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site
+ ?: return
+ callback?.invoke(model)
+ }
+ }
+ }
+
+ @OnLifecycleEvent(ON_RESUME)
+ fun onResume() {
+ val filter = intentProvider.createFilter(ACTION_STATUS_UPDATE)
+ context.registerReceiver(intentReceiver, filter)
+ }
+
+ @OnLifecycleEvent(ON_PAUSE)
+ fun onPause() {
+ context.unregisterReceiver(intentReceiver)
+ }
+
+ @OnLifecycleEvent(ON_DESTROY)
+ fun onDestroy() {
+ callback = null
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.java b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.java
deleted file mode 100644
index 3eeed80..0000000
--- a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.afollestad.nocknock.dialogs;
-
-import android.app.Dialog;
-import android.os.Bundle;
-import android.support.annotation.NonNull;
-import android.support.v4.app.DialogFragment;
-import android.support.v7.app.AppCompatActivity;
-import android.text.Html;
-
-import com.afollestad.materialdialogs.MaterialDialog;
-import com.afollestad.nocknock.R;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class AboutDialog extends DialogFragment {
-
- public static void show(AppCompatActivity context) {
- AboutDialog dialog = new AboutDialog();
- dialog.show(context.getSupportFragmentManager(), "[ABOUT_DIALOG]");
- }
-
- @NonNull
- @Override
- public Dialog onCreateDialog(Bundle savedInstanceState) {
- return new MaterialDialog.Builder(getActivity())
- .title(R.string.about)
- .positiveText(R.string.dismiss)
- .content(Html.fromHtml(getString(R.string.about_body)))
- .contentLineSpacing(1.6f)
- .build();
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt
new file mode 100644
index 0000000..f0c152f
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt
@@ -0,0 +1,44 @@
+/**
+ * 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.dialogs
+
+import android.app.Dialog
+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) */
+class AboutDialog : DialogFragment() {
+ companion object {
+ private const val TAG = "[ABOUT_DIALOG]"
+
+ fun show(context: AppCompatActivity) {
+ val dialog = AboutDialog()
+ dialog.show(context.supportFragmentManager, TAG)
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val context = activity ?: throw IllegalStateException("Oh no!")
+ return MaterialDialog(context)
+ .title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME))
+ .positiveButton(R.string.dismiss)
+ .message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt
new file mode 100644
index 0000000..07f6410
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt
@@ -0,0 +1,72 @@
+/**
+ * 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.koin
+
+import android.app.Application
+import android.app.NotificationManager
+import android.app.job.JobScheduler
+import android.content.Context.JOB_SCHEDULER_SERVICE
+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
+import okhttp3.OkHttpClient
+import org.koin.dsl.module.module
+
+val mainActivityCls = MainActivity::class.java
+
+/** @author Aidan Follestad (@afollestad) */
+val mainModule = module {
+
+ single(name = MAIN_ACTIVITY_CLASS) { mainActivityCls }
+
+ single {
+ databaseBuilder(get(), AppDatabase::class.java, "NockNock.db")
+ .addMigrations(
+ Database1to2Migration(),
+ Database2to3Migration(),
+ Database3to4Migration(),
+ Database4to5Migration()
+ )
+ .build()
+ }
+
+ single {
+ OkHttpClient.Builder()
+ .addNetworkInterceptor { chain ->
+ val request = chain.request()
+ .newBuilder()
+ .addHeader("User-Agent", "com.afollestad.nocknock")
+ .build()
+ chain.proceed(request)
+ }
+ .build()
+ }
+
+ single {
+ get().systemService(JOB_SCHEDULER_SERVICE)
+ }
+
+ single {
+ get().systemService(NOTIFICATION_SERVICE)
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt
new file mode 100644
index 0000000..654b76d
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt
@@ -0,0 +1,32 @@
+/**
+ * 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.koin
+
+import com.afollestad.rxkprefs.RxkPrefs
+import com.afollestad.rxkprefs.rxkPrefs
+import org.koin.dsl.module.module
+
+const val PREF_DARK_MODE = "dark_mode"
+
+/** @author Aidan Follestad (@afollestad) */
+val prefModule = module {
+
+ single { rxkPrefs(get(), "settings") }
+
+ factory(name = PREF_DARK_MODE) {
+ get().boolean(PREF_DARK_MODE, false)
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt
new file mode 100644
index 0000000..ab8cd79
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt
@@ -0,0 +1,58 @@
+/**
+ * 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.koin
+
+import com.afollestad.nocknock.ui.addsite.AddSiteViewModel
+import com.afollestad.nocknock.ui.main.MainViewModel
+import com.afollestad.nocknock.ui.viewsite.ViewSiteViewModel
+import com.afollestad.nocknock.utilities.Qualifiers.IO_DISPATCHER
+import com.afollestad.nocknock.utilities.Qualifiers.MAIN_DISPATCHER
+import org.koin.androidx.viewmodel.ext.koin.viewModel
+import org.koin.dsl.module.module
+
+/** @author Aidan Follestad (@afollestad) */
+val viewModelModule = module {
+
+ viewModel {
+ MainViewModel(
+ get(),
+ get(),
+ get(),
+ get(name = MAIN_DISPATCHER),
+ get(name = IO_DISPATCHER)
+ )
+ }
+
+ viewModel {
+ AddSiteViewModel(
+ get(),
+ get(),
+ get(name = MAIN_DISPATCHER),
+ get(name = IO_DISPATCHER)
+ )
+ }
+
+ viewModel {
+ ViewSiteViewModel(
+ get(),
+ get(),
+ get(),
+ get(),
+ get(name = MAIN_DISPATCHER),
+ get(name = IO_DISPATCHER)
+ )
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt b/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt
new file mode 100644
index 0000000..10b97fb
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt
@@ -0,0 +1,37 @@
+/**
+ * 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.logging
+
+import com.crashlytics.android.Crashlytics
+import timber.log.Timber
+
+/** @author Aidan Follestad (@afollestad) */
+class FabricTree : Timber.Tree() {
+
+ override fun log(
+ priority: Int,
+ tag: String?,
+ message: String,
+ t: Throwable?
+ ) {
+ if (t != null) {
+ Crashlytics.setString("crash_tag", tag)
+ Crashlytics.logException(t)
+ } else {
+ Crashlytics.log(priority, tag, message)
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/receivers/BootReceiver.java b/app/src/main/java/com/afollestad/nocknock/receivers/BootReceiver.java
deleted file mode 100644
index 4f5666f..0000000
--- a/app/src/main/java/com/afollestad/nocknock/receivers/BootReceiver.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.afollestad.nocknock.receivers;
-
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-
-import com.afollestad.inquiry.Inquiry;
-import com.afollestad.nocknock.api.ServerModel;
-import com.afollestad.nocknock.ui.MainActivity;
-import com.afollestad.nocknock.util.AlarmUtil;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class BootReceiver extends BroadcastReceiver {
-
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals("android.intent.action.BOOT_COMPLETED")) {
- final Inquiry inq = Inquiry.newInstance(context, MainActivity.DB_NAME).build(false);
- ServerModel[] models = inq
- .selectFrom(MainActivity.SITES_TABLE_NAME, ServerModel.class)
- .all();
- AlarmUtil.setSiteChecks(context, models);
- inq.destroyInstance();
- }
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/receivers/ConnectivityReceiver.java b/app/src/main/java/com/afollestad/nocknock/receivers/ConnectivityReceiver.java
deleted file mode 100644
index 7ceed62..0000000
--- a/app/src/main/java/com/afollestad/nocknock/receivers/ConnectivityReceiver.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.afollestad.nocknock.receivers;
-
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.util.Log;
-
-import com.afollestad.nocknock.services.CheckService;
-import com.afollestad.nocknock.util.NetworkUtil;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class ConnectivityReceiver extends BroadcastReceiver {
-
- @Override
- public void onReceive(Context context, Intent intent) {
- final boolean hasInternet = NetworkUtil.hasInternet(context);
- Log.v("ConnectivityReceiver", "Connectivity state changed... has internet? " + hasInternet);
- if (hasInternet) {
- context.startService(new Intent(context, CheckService.class)
- .putExtra(CheckService.ONLY_WAITING, true));
- }
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/services/CheckService.java b/app/src/main/java/com/afollestad/nocknock/services/CheckService.java
deleted file mode 100644
index 2b9ab94..0000000
--- a/app/src/main/java/com/afollestad/nocknock/services/CheckService.java
+++ /dev/null
@@ -1,241 +0,0 @@
-package com.afollestad.nocknock.services;
-
-import android.app.IntentService;
-import android.app.Notification;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.BitmapFactory;
-import android.os.IBinder;
-import android.preference.PreferenceManager;
-import android.support.annotation.Nullable;
-import android.support.v4.app.NotificationCompat;
-import android.support.v4.app.NotificationManagerCompat;
-import android.util.Log;
-import android.widget.Toast;
-
-import com.afollestad.bridge.Bridge;
-import com.afollestad.bridge.BridgeException;
-import com.afollestad.bridge.Response;
-import com.afollestad.inquiry.Inquiry;
-import com.afollestad.inquiry.Query;
-import com.afollestad.nocknock.BuildConfig;
-import com.afollestad.nocknock.R;
-import com.afollestad.nocknock.api.ServerModel;
-import com.afollestad.nocknock.api.ServerStatus;
-import com.afollestad.nocknock.api.ValidationMode;
-import com.afollestad.nocknock.ui.MainActivity;
-import com.afollestad.nocknock.ui.ViewSiteActivity;
-import com.afollestad.nocknock.util.JsUtil;
-import com.afollestad.nocknock.util.NetworkUtil;
-
-import java.util.Locale;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-@SuppressWarnings("CheckResult")
-public class CheckService extends IntentService {
-
- public static String ACTION_CHECK_UPDATE = BuildConfig.APPLICATION_ID + ".CHECK_UPDATE";
- public static String ACTION_RUNNING = BuildConfig.APPLICATION_ID + ".CHECK_RUNNING";
- public static String MODEL_ID = "model_id";
- public static String ONLY_WAITING = "only_waiting";
- public static int NOTI_ID = 3456;
-
- public CheckService() {
- super("NockNockCheckService");
- }
-
- private static void LOG(String msg, Object... format) {
- if (format != null)
- msg = String.format(Locale.getDefault(), msg, format);
- Log.v("NockNockService", msg);
- }
-
- @Nullable
- @Override
- public IBinder onBind(Intent intent) {
- return null;
- }
-
- @Override
- protected void onHandleIntent(Intent intent) {
- Inquiry.newInstance(this, MainActivity.DB_NAME).build();
- isRunning(true);
- Bridge.config()
- .defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)");
-
- final Query query = Inquiry.get(this)
- .selectFrom(MainActivity.SITES_TABLE_NAME, ServerModel.class);
- if (intent != null && intent.hasExtra(MODEL_ID)) {
- query.where("_id = ?", intent.getLongExtra(MODEL_ID, -1));
- } else if (intent != null && intent.getBooleanExtra(ONLY_WAITING, false)) {
- query.where("status = ?", ServerStatus.WAITING);
- }
- final ServerModel[] sites = query.all();
-
- if (sites == null || sites.length == 0) {
- LOG("No sites added to check, service will terminate.");
- isRunning(false);
- stopSelf();
- return;
- }
-
- LOG("Checking %d sites...", sites.length);
- sendBroadcast(new Intent(ACTION_RUNNING));
-
- for (ServerModel site : sites) {
- LOG("Updating %s (%s) status to WAITING...", site.name, site.url);
- site.status = ServerStatus.WAITING;
- updateStatus(site);
- }
-
- if (NetworkUtil.hasInternet(this)) {
- for (ServerModel site : sites) {
- LOG("Checking %s (%s)...", site.name, site.url);
- site.status = ServerStatus.CHECKING;
- site.lastCheck = System.currentTimeMillis();
- updateStatus(site);
-
- try {
- final Response response = Bridge.get(site.url)
- .throwIfNotSuccess()
- .cancellable(false)
- .request()
- .response();
-
- site.reason = null;
- site.status = ServerStatus.OK;
-
- if (site.validationMode == ValidationMode.TERM_SEARCH) {
- final String body = response.asString();
- if (body == null || !body.contains(site.validationContent)) {
- site.status = ServerStatus.ERROR;
- site.reason = "Term \"" + site.validationContent + "\" not found in response body.";
- }
- } else if (site.validationMode == ValidationMode.JAVASCRIPT) {
- final String body = response.asString();
- site.reason = JsUtil.exec(site.validationContent, body);
- if (site.reason != null && !site.toString().isEmpty())
- site.status = ServerStatus.ERROR;
- }
-
- if (site.status == ServerStatus.ERROR)
- showNotification(this, site);
- } catch (BridgeException e) {
- processError(e, site);
- }
- updateStatus(site);
- }
- } else {
- LOG("No internet connection, waiting.");
- }
-
- isRunning(false);
- LOG("Service is finished!");
- }
-
- private void processError(BridgeException e, ServerModel site) {
- site.status = ServerStatus.OK;
- site.reason = null;
-
- switch (e.reason()) {
- case BridgeException.REASON_REQUEST_CANCELLED:
- // Shouldn't happen
- break;
- case BridgeException.REASON_REQUEST_FAILED:
- case BridgeException.REASON_RESPONSE_UNPARSEABLE:
- case BridgeException.REASON_RESPONSE_UNSUCCESSFUL:
- case BridgeException.REASON_RESPONSE_IOERROR:
- //noinspection ConstantConditions
- if (e.response() != null && e.response().code() == 401) {
- // Don't consider 401 unsuccessful here
- site.reason = null;
- } else {
- site.status = ServerStatus.ERROR;
- site.reason = e.getMessage();
- }
- break;
- case BridgeException.REASON_REQUEST_TIMEOUT:
- site.status = ServerStatus.ERROR;
- site.reason = getString(R.string.timeout);
- break;
- case BridgeException.REASON_RESPONSE_VALIDATOR_ERROR:
- case BridgeException.REASON_RESPONSE_VALIDATOR_FALSE:
- // Not used
- break;
- }
-
- if (site.status != ServerStatus.OK) {
- LOG("%s error: %s", site.name, site.reason);
- showNotification(this, site);
- }
- }
-
- private void updateStatus(ServerModel site) {
- Inquiry.get(this)
- .update(MainActivity.SITES_TABLE_NAME, ServerModel.class)
- .where("_id = ?", site.id)
- .values(site)
- .run();
- sendBroadcast(new Intent(ACTION_CHECK_UPDATE)
- .putExtra("model", site));
- }
-
- private void isRunning(boolean running) {
- PreferenceManager.getDefaultSharedPreferences(this)
- .edit().putBoolean("check_service_running", running).commit();
- }
-
- public static boolean isRunning(Context context) {
- return PreferenceManager.getDefaultSharedPreferences(context)
- .getBoolean("check_service_running", false);
- }
-
- public static void isAppOpen(Context context, boolean open) {
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit().putBoolean("is_app_open", open).commit();
- }
-
- public static boolean isAppOpen(Context context) {
- return PreferenceManager.getDefaultSharedPreferences(context)
- .getBoolean("is_app_open", false);
- }
-
- private static void showNotification(Context context, ServerModel site) {
- if (isAppOpen(context)) {
- // Don't show notifications while the app is open
- return;
- }
-
- final NotificationManagerCompat nm = NotificationManagerCompat.from(context);
- final PendingIntent openIntent = PendingIntent.getActivity(context, 9669,
- new Intent(context, ViewSiteActivity.class)
- .putExtra("model", site)
- .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
- PendingIntent.FLAG_CANCEL_CURRENT);
- final Notification noti = new NotificationCompat.Builder(context)
- .setContentTitle(site.name)
- .setContentText(context.getString(R.string.something_wrong))
- .setContentIntent(openIntent)
- .setSmallIcon(R.drawable.ic_notification)
- .setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher))
- .setPriority(Notification.PRIORITY_HIGH)
- .setAutoCancel(true)
- .setDefaults(Notification.DEFAULT_VIBRATE)
- .build();
- nm.notify(site.url, NOTI_ID, noti);
- }
-
- @Override
- public void onDestroy() {
- try {
- Inquiry.destroy(this);
- } catch (Throwable t2) {
- t2.printStackTrace();
- }
- super.onDestroy();
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.java b/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.java
deleted file mode 100644
index cb54ece..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.java
+++ /dev/null
@@ -1,255 +0,0 @@
-package com.afollestad.nocknock.ui;
-
-import android.animation.Animator;
-import android.animation.AnimatorListenerAdapter;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.support.design.widget.TextInputLayout;
-import android.support.v7.app.AppCompatActivity;
-import android.support.v7.widget.Toolbar;
-import android.util.Patterns;
-import android.view.View;
-import android.view.ViewAnimationUtils;
-import android.view.ViewTreeObserver;
-import android.view.animation.AccelerateInterpolator;
-import android.view.animation.DecelerateInterpolator;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
-import android.widget.EditText;
-import android.widget.Spinner;
-import android.widget.TextView;
-
-import com.afollestad.nocknock.R;
-import com.afollestad.nocknock.api.ServerModel;
-import com.afollestad.nocknock.api.ServerStatus;
-import com.afollestad.nocknock.api.ValidationMode;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class AddSiteActivity extends AppCompatActivity implements View.OnClickListener {
-
- private View rootLayout;
- private Toolbar toolbar;
-
- private TextInputLayout nameTiLayout;
- private EditText inputName;
- private TextInputLayout urlTiLayout;
- private EditText inputUrl;
- private EditText inputInterval;
- private Spinner spinnerInterval;
- private TextView textUrlWarning;
- private Spinner responseValidationSpinner;
-
- private boolean isClosing;
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_addsite);
-
- rootLayout = findViewById(R.id.rootView);
- nameTiLayout = (TextInputLayout) findViewById(R.id.nameTiLayout);
- inputName = (EditText) findViewById(R.id.inputName);
- urlTiLayout = (TextInputLayout) findViewById(R.id.urlTiLayout);
- inputUrl = (EditText) findViewById(R.id.inputUrl);
- textUrlWarning = (TextView) findViewById(R.id.textUrlWarning);
- inputInterval = (EditText) findViewById(R.id.checkIntervalInput);
- spinnerInterval = (Spinner) findViewById(R.id.checkIntervalSpinner);
- responseValidationSpinner = (Spinner) findViewById(R.id.responseValidationMode);
-
- toolbar = (Toolbar) findViewById(R.id.toolbar);
- toolbar.setNavigationOnClickListener(view -> closeActivityWithReveal());
-
- if (savedInstanceState == null) {
- rootLayout.setVisibility(View.INVISIBLE);
- ViewTreeObserver viewTreeObserver = rootLayout.getViewTreeObserver();
- if (viewTreeObserver.isAlive()) {
- viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
- @Override
- public void onGlobalLayout() {
- circularRevealActivity();
- rootLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this);
- }
- });
- }
- }
-
- ArrayAdapter intervalOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner,
- getResources().getStringArray(R.array.interval_options));
- intervalOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown);
- spinnerInterval.setAdapter(intervalOptionsAdapter);
-
- inputUrl.setOnFocusChangeListener((view, hasFocus) -> {
- if (!hasFocus) {
- final String inputStr = inputUrl.getText().toString().trim();
- if (inputStr.isEmpty()) return;
- final Uri uri = Uri.parse(inputStr);
- if (uri.getScheme() == null) {
- inputUrl.setText("http://" + inputStr);
- textUrlWarning.setVisibility(View.GONE);
- } else if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
- textUrlWarning.setVisibility(View.VISIBLE);
- textUrlWarning.setText(R.string.warning_http_url);
- } else {
- textUrlWarning.setVisibility(View.GONE);
- }
- }
- });
-
- ArrayAdapter validationOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner,
- getResources().getStringArray(R.array.response_validation_options));
- validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown);
- responseValidationSpinner.setAdapter(validationOptionsAdapter);
- responseValidationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
- @Override
- public void onItemSelected(AdapterView> adapterView, View view, int i, long l) {
- final View searchTerm = findViewById(R.id.responseValidationSearchTerm);
- final View javascript = findViewById(R.id.responseValidationScript);
- final TextView modeDesc = (TextView) findViewById(R.id.validationModeDescription);
-
- searchTerm.setVisibility(i == 1 ? View.VISIBLE : View.GONE);
- javascript.setVisibility(i == 2 ? View.VISIBLE : View.GONE);
-
- switch (i) {
- case 0:
- modeDesc.setText(R.string.validation_mode_status_desc);
- break;
- case 1:
- modeDesc.setText(R.string.validation_mode_term_desc);
- break;
- case 2:
- modeDesc.setText(R.string.validation_mode_javascript_desc);
- break;
- }
- }
-
- @Override
- public void onNothingSelected(AdapterView> adapterView) {
- }
- });
-
- findViewById(R.id.doneBtn).setOnClickListener(this);
- }
-
- @Override
- public void onBackPressed() {
- closeActivityWithReveal();
- }
-
- private void closeActivityWithReveal() {
- if (isClosing) return;
- isClosing = true;
- final int fabSize = getIntent().getIntExtra("fab_size", toolbar.getMeasuredHeight());
- final int cx = (int) getIntent().getFloatExtra("fab_x", rootLayout.getMeasuredWidth() / 2) + (fabSize / 2);
- final int cy = (int) getIntent().getFloatExtra("fab_y", rootLayout.getMeasuredHeight() / 2) + toolbar.getMeasuredHeight() + (fabSize / 2);
- float initialRadius = Math.max(cx, cy);
-
- final Animator circularReveal = ViewAnimationUtils.createCircularReveal(rootLayout, cx, cy, initialRadius, 0);
- circularReveal.setDuration(300);
- circularReveal.setInterpolator(new AccelerateInterpolator());
- circularReveal.addListener(new AnimatorListenerAdapter() {
- @Override
- public void onAnimationEnd(Animator animation) {
- super.onAnimationEnd(animation);
- rootLayout.setVisibility(View.INVISIBLE);
- finish();
- overridePendingTransition(0, 0);
- }
- });
-
- circularReveal.start();
- }
-
- private void circularRevealActivity() {
- final int cx = rootLayout.getMeasuredWidth() / 2;
- final int cy = rootLayout.getMeasuredHeight() / 2;
- final float finalRadius = Math.max(cx, cy);
- final Animator circularReveal = ViewAnimationUtils.createCircularReveal(rootLayout, cx, cy, 0, finalRadius);
-
- circularReveal.setDuration(300);
- circularReveal.setInterpolator(new DecelerateInterpolator());
-
- rootLayout.setVisibility(View.VISIBLE);
- circularReveal.start();
- }
-
- // Done button
- @Override
- public void onClick(View view) {
- isClosing = true;
-
- ServerModel model = new ServerModel();
- model.name = inputName.getText().toString().trim();
- model.url = inputUrl.getText().toString().trim();
- model.status = ServerStatus.WAITING;
-
- if (model.name.isEmpty()) {
- nameTiLayout.setError(getString(R.string.please_enter_name));
- isClosing = false;
- return;
- } else {
- nameTiLayout.setError(null);
- }
-
- if (model.url.isEmpty()) {
- urlTiLayout.setError(getString(R.string.please_enter_url));
- isClosing = false;
- return;
- } else {
- urlTiLayout.setError(null);
- if (!Patterns.WEB_URL.matcher(model.url).find()) {
- urlTiLayout.setError(getString(R.string.please_enter_valid_url));
- isClosing = false;
- return;
- } else {
- final Uri uri = Uri.parse(model.url);
- if (uri.getScheme() == null)
- model.url = "http://" + model.url;
- }
- }
-
- String intervalStr = inputInterval.getText().toString().trim();
- if (intervalStr.isEmpty()) intervalStr = "0";
- model.checkInterval = Integer.parseInt(intervalStr);
-
- switch (spinnerInterval.getSelectedItemPosition()) {
- case 0: // minutes
- model.checkInterval *= (60 * 1000);
- break;
- case 1: // hours
- model.checkInterval *= (60 * 60 * 1000);
- break;
- case 2: // days
- model.checkInterval *= (60 * 60 * 24 * 1000);
- break;
- default: // weeks
- model.checkInterval *= (60 * 60 * 24 * 7 * 1000);
- break;
- }
-
- model.lastCheck = System.currentTimeMillis() - model.checkInterval;
-
- switch (responseValidationSpinner.getSelectedItemPosition()) {
- case 0:
- model.validationMode = ValidationMode.STATUS_CODE;
- model.validationContent = null;
- break;
- case 1:
- model.validationMode = ValidationMode.TERM_SEARCH;
- model.validationContent = ((EditText) findViewById(R.id.responseValidationSearchTerm)).getText().toString().trim();
- break;
- case 2:
- model.validationMode = ValidationMode.JAVASCRIPT;
- model.validationContent = ((EditText) findViewById(R.id.responseValidationScriptInput)).getText().toString().trim();
- break;
- }
-
- setResult(RESULT_OK, new Intent()
- .putExtra("model", model));
- finish();
- overridePendingTransition(R.anim.fade_out, R.anim.fade_out);
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt
new file mode 100644
index 0000000..3220567
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt
@@ -0,0 +1,82 @@
+/**
+ * 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
+
+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
+import timber.log.Timber.d as log
+
+/** @author Aidan Follestad (afollestad) */
+abstract class DarkModeSwitchActivity : AppCompatActivity() {
+
+ private var isDarkModeEnabled: Boolean = false
+ private val darkModePref by inject>(name = PREF_DARK_MODE)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ isDarkModeEnabled = isDarkMode()
+ setTheme(themeRes())
+ super.onCreate(savedInstanceState)
+
+ if (getCurrentNightMode() == UNKNOWN) {
+ darkModePref.observe()
+ .filter { it != isDarkModeEnabled }
+ .subscribe {
+ log("Theme changed, recreating Activity.")
+ recreate()
+ }
+ .attachLifecycle(this)
+ }
+ }
+
+ 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())
+
+ private fun setDarkMode(darkMode: Boolean) = darkModePref.set(darkMode)
+
+ private fun themeRes() = if (isDarkMode()) {
+ R.style.AppTheme_Dark
+ } else {
+ R.style.AppTheme
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.java b/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.java
deleted file mode 100644
index 88f8ba8..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.java
+++ /dev/null
@@ -1,326 +0,0 @@
-package com.afollestad.nocknock.ui;
-
-import android.animation.Animator;
-import android.animation.AnimatorListenerAdapter;
-import android.animation.ObjectAnimator;
-import android.annotation.SuppressLint;
-import android.app.ActivityOptions;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.SharedPreferences;
-import android.graphics.Path;
-import android.os.Bundle;
-import android.preference.PreferenceManager;
-import android.support.design.widget.FloatingActionButton;
-import android.support.v4.app.NotificationManagerCompat;
-import android.support.v4.content.ContextCompat;
-import android.support.v4.widget.SwipeRefreshLayout;
-import android.support.v7.app.AppCompatActivity;
-import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
-import android.text.Html;
-import android.util.Log;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.animation.PathInterpolator;
-import android.widget.TextView;
-
-import com.afollestad.bridge.Bridge;
-import com.afollestad.inquiry.Inquiry;
-import com.afollestad.materialdialogs.MaterialDialog;
-import com.afollestad.nocknock.R;
-import com.afollestad.nocknock.adapter.ServerAdapter;
-import com.afollestad.nocknock.api.ServerModel;
-import com.afollestad.nocknock.api.ValidationMode;
-import com.afollestad.nocknock.dialogs.AboutDialog;
-import com.afollestad.nocknock.services.CheckService;
-import com.afollestad.nocknock.util.AlarmUtil;
-import com.afollestad.nocknock.util.MathUtil;
-import com.afollestad.nocknock.views.DividerItemDecoration;
-
-public class MainActivity extends AppCompatActivity implements SwipeRefreshLayout.OnRefreshListener, View.OnClickListener, ServerAdapter.ClickListener {
-
- private final static int ADD_SITE_RQ = 6969;
- private final static int VIEW_SITE_RQ = 6923;
- public final static String DB_NAME = "nock_nock";
- public final static String SITES_TABLE_NAME_OLD = "sites";
- public final static String SITES_TABLE_NAME = "site_models";
-
- private FloatingActionButton mFab;
- private RecyclerView mList;
- private ServerAdapter mAdapter;
- private TextView mEmptyText;
- private SwipeRefreshLayout mRefreshLayout;
-
- private ObjectAnimator mFabAnimator;
- private float mOrigFabX;
- private float mOrigFabY;
-
- private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- Log.v("MainActivity", "Received " + intent.getAction());
- if (CheckService.ACTION_RUNNING.equals(intent.getAction())) {
- if (mRefreshLayout != null)
- mRefreshLayout.setRefreshing(false);
- } else {
- final ServerModel model = (ServerModel) intent.getSerializableExtra("model");
- if (mAdapter != null && mList != null && model != null) {
- mList.post(() -> mAdapter.update(model));
- }
- }
- }
- };
-
- @SuppressLint("CommitPrefEdits")
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_main);
-
- mAdapter = new ServerAdapter(this);
- mEmptyText = (TextView) findViewById(R.id.emptyText);
-
- mList = (RecyclerView) findViewById(R.id.list);
- mList.setLayoutManager(new LinearLayoutManager(this));
- mList.setAdapter(mAdapter);
- mList.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST));
-
- mRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipeRefreshLayout);
- mRefreshLayout.setOnRefreshListener(this);
- mRefreshLayout.setColorSchemeColors(ContextCompat.getColor(this, R.color.md_green),
- ContextCompat.getColor(this, R.color.md_yellow),
- ContextCompat.getColor(this, R.color.md_red));
-
- mFab = (FloatingActionButton) findViewById(R.id.fab);
- mFab.setOnClickListener(this);
-
- Inquiry.newInstance(this, DB_NAME).build();
- Bridge.config()
- .defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)");
-
- final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
- if (!sp.getBoolean("migrated_db", false)) {
- final Inquiry mdb = Inquiry.newInstance(this, DB_NAME)
- .instanceName("migrate_db")
- .build(false);
- final ServerModel[] models = Inquiry.get(this)
- .selectFrom(SITES_TABLE_NAME_OLD, ServerModel.class)
- .projection("_id", "name", "url", "status", "checkInterval", "lastCheck", "reason")
- .all();
- if (models != null) {
- Log.d("SiteMigration", "Migrating " + models.length + " sites to the new table.");
- for (ServerModel model : models) {
- model.validationMode = ValidationMode.STATUS_CODE;
- model.validationContent = null;
- }
- //noinspection CheckResult
- mdb.insertInto(SITES_TABLE_NAME, ServerModel.class)
- .values(models)
- .run();
- mdb.dropTable(SITES_TABLE_NAME_OLD);
- }
- sp.edit().putBoolean("migrated_db", true).commit();
- }
- }
-
- private void showRefreshTutorial() {
- if (mAdapter.getItemCount() == 0) return;
- final SharedPreferences pr = PreferenceManager.getDefaultSharedPreferences(this);
- if (pr.getBoolean("shown_swipe_refresh_tutorial", false)) return;
-
- mFab.hide();
- final View tutorialView = findViewById(R.id.swipeRefreshTutorial);
- tutorialView.setVisibility(View.VISIBLE);
- tutorialView.setAlpha(0f);
- tutorialView.animate().cancel();
- tutorialView.animate().setDuration(300).alpha(1f).start();
-
- findViewById(R.id.understoodBtn).setOnClickListener(view -> {
- view.setOnClickListener(null);
- findViewById(R.id.swipeRefreshTutorial).setVisibility(View.GONE);
- pr.edit().putBoolean("shown_swipe_refresh_tutorial", true).commit();
- mFab.show();
- });
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- CheckService.isAppOpen(this, true);
-
- try {
- final IntentFilter filter = new IntentFilter();
- filter.addAction(CheckService.ACTION_CHECK_UPDATE);
- filter.addAction(CheckService.ACTION_RUNNING);
- registerReceiver(mReceiver, filter);
- } catch (Throwable t) {
- t.printStackTrace();
- }
-
- refreshModels();
- }
-
- @Override
- protected void onPause() {
- super.onPause();
- CheckService.isAppOpen(this, false);
-
- if (isFinishing()) {
- Inquiry.destroy(this);
- }
-
- NotificationManagerCompat.from(this).cancel(CheckService.NOTI_ID);
- try {
- unregisterReceiver(mReceiver);
- } catch (Throwable t) {
- t.printStackTrace();
- }
- }
-
- private void refreshModels() {
- mAdapter.clear();
- mEmptyText.setVisibility(View.VISIBLE);
- Inquiry.get(this)
- .selectFrom(SITES_TABLE_NAME, ServerModel.class)
- .all(this::setModels);
- }
-
- private void setModels(ServerModel[] models) {
- mAdapter.set(models);
- mEmptyText.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE);
- AlarmUtil.setSiteChecks(this, models);
- showRefreshTutorial();
- }
-
- @Override
- public boolean onCreateOptionsMenu(Menu menu) {
- getMenuInflater().inflate(R.menu.menu_main, menu);
- return super.onCreateOptionsMenu(menu);
- }
-
- @Override
- public boolean onOptionsItemSelected(MenuItem item) {
- if (item.getItemId() == R.id.about) {
- AboutDialog.show(this);
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
-
- @Override
- public void onRefresh() {
- if (CheckService.isRunning(this)) {
- mRefreshLayout.setRefreshing(false);
- return;
- }
- startService(new Intent(this, CheckService.class));
- }
-
- // FAB clicked
- @Override
- public void onClick(View view) {
- mOrigFabX = mFab.getX();
- mOrigFabY = mFab.getY();
- final Path curve = MathUtil.bezierCurve(mFab, mList);
- if (mFabAnimator != null)
- mFabAnimator.cancel();
- mFabAnimator = ObjectAnimator.ofFloat(view, View.X, View.Y, curve);
- mFabAnimator.setInterpolator(new PathInterpolator(.5f, .5f));
- mFabAnimator.setDuration(300);
- mFabAnimator.addListener(new AnimatorListenerAdapter() {
- @Override
- public void onAnimationEnd(Animator animation) {
- super.onAnimationEnd(animation);
- startActivityForResult(new Intent(MainActivity.this, AddSiteActivity.class)
- .putExtra("fab_x", mOrigFabX)
- .putExtra("fab_y", mOrigFabY)
- .putExtra("fab_size", mFab.getMeasuredWidth())
- .addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION), ADD_SITE_RQ);
- mFab.postDelayed(() -> {
- mFab.setX(mOrigFabX);
- mFab.setY(mOrigFabY);
- }, 600);
- }
- });
- mFabAnimator.start();
- }
-
- @Override
- protected void onActivityResult(int requestCode, int resultCode, Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- if (resultCode == RESULT_OK) {
- final ServerModel model = (ServerModel) data.getSerializableExtra("model");
- if (requestCode == ADD_SITE_RQ) {
- mAdapter.add(model);
- mEmptyText.setVisibility(View.GONE);
- Inquiry.get(this).insertInto(SITES_TABLE_NAME, ServerModel.class)
- .values(model)
- .run(inserted -> {
- AlarmUtil.setSiteChecks(MainActivity.this, model);
- checkSite(MainActivity.this, model);
- });
- } else if (requestCode == VIEW_SITE_RQ) {
- mAdapter.update(model);
- AlarmUtil.setSiteChecks(MainActivity.this, model);
- checkSite(MainActivity.this, model);
- }
- }
- }
-
- public static void removeSite(final Context context, final ServerModel model, final Runnable onRemoved) {
- new MaterialDialog.Builder(context)
- .title(R.string.remove_site)
- .content(Html.fromHtml(context.getString(R.string.remove_site_prompt, model.name)))
- .positiveText(R.string.remove)
- .negativeText(android.R.string.cancel)
- .onPositive((dialog, which) -> {
- AlarmUtil.cancelSiteChecks(context, model);
- final NotificationManagerCompat nm = NotificationManagerCompat.from(context);
- nm.cancel(model.url, CheckService.NOTI_ID);
- //noinspection CheckResult
- final Inquiry rinq = Inquiry.newInstance(context, DB_NAME)
- .instanceName("remove_site")
- .build(false);
- //noinspection CheckResult
- rinq.deleteFrom(SITES_TABLE_NAME, ServerModel.class)
- .where("_id = ?", model.id)
- .run();
- rinq.destroyInstance();
- if (onRemoved != null)
- onRemoved.run();
- }).show();
- }
-
- public static void checkSite(Context context, ServerModel model) {
- context.startService(new Intent(context, CheckService.class)
- .putExtra(CheckService.MODEL_ID, model.id));
- }
-
- @Override
- public void onSiteSelected(final int index, final ServerModel model, boolean longClick) {
- if (longClick) {
- new MaterialDialog.Builder(this)
- .title(R.string.options)
- .items(R.array.site_long_options)
- .negativeText(android.R.string.cancel)
- .itemsCallback((dialog, itemView, which, text) -> {
- if (which == 0) {
- checkSite(MainActivity.this, model);
- } else {
- removeSite(MainActivity.this, model, () -> {
- mAdapter.remove(index);
- mEmptyText.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE);
- });
- }
- }).show();
- } else {
- startActivityForResult(new Intent(this, ViewSiteActivity.class)
- .putExtra("model", model), VIEW_SITE_RQ,
- ActivityOptions.makeSceneTransitionAnimation(this).toBundle());
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt
new file mode 100644
index 0000000..2930fea
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt
@@ -0,0 +1,26 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui
+
+/** @author Aidan Follestad (@afollestad) */
+enum class NightMode {
+ /** Night mode is on at the system level. */
+ ENABLED,
+ /** Night mode is off at the system level. */
+ DISABLED,
+ /** We don't know about night mode, fallback to custom impl. */
+ UNKNOWN
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt
new file mode 100644
index 0000000..750173a
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt
@@ -0,0 +1,36 @@
+/**
+ * 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
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import org.jetbrains.annotations.TestOnly
+
+/** @author Aidan Follestad (@afollestad) */
+abstract class ScopedViewModel(mainDispatcher: CoroutineDispatcher) : ViewModel() {
+
+ private val job = Job()
+ protected val scope = CoroutineScope(job + mainDispatcher)
+
+ override fun onCleared() {
+ super.onCleared()
+ job.cancel()
+ }
+
+ @TestOnly open fun destroy() = job.cancel()
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.java b/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.java
deleted file mode 100644
index 5edbef3..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.java
+++ /dev/null
@@ -1,340 +0,0 @@
-package com.afollestad.nocknock.ui;
-
-import android.annotation.SuppressLint;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.net.Uri;
-import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.support.v7.app.AppCompatActivity;
-import android.support.v7.widget.Toolbar;
-import android.util.Log;
-import android.util.Patterns;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
-import android.widget.EditText;
-import android.widget.Spinner;
-import android.widget.TextView;
-
-import com.afollestad.bridge.Bridge;
-import com.afollestad.inquiry.Inquiry;
-import com.afollestad.nocknock.R;
-import com.afollestad.nocknock.api.ServerModel;
-import com.afollestad.nocknock.api.ServerStatus;
-import com.afollestad.nocknock.api.ValidationMode;
-import com.afollestad.nocknock.services.CheckService;
-import com.afollestad.nocknock.util.TimeUtil;
-import com.afollestad.nocknock.views.StatusImageView;
-
-import java.text.SimpleDateFormat;
-import java.util.Date;
-import java.util.Locale;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class ViewSiteActivity extends AppCompatActivity implements View.OnClickListener, Toolbar.OnMenuItemClickListener {
-
- private StatusImageView iconStatus;
- private EditText inputName;
- private EditText inputUrl;
- private EditText inputCheckInterval;
- private Spinner checkIntervalSpinner;
- private TextView textLastCheckResult;
- private TextView textNextCheck;
- private TextView textUrlWarning;
- private Spinner responseValidationSpinner;
-
- private ServerModel mModel;
-
- private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- Log.v("ViewSiteActivity", "Received " + intent.getAction());
- final ServerModel model = (ServerModel) intent.getSerializableExtra("model");
- if (model != null) {
- mModel = model;
- update();
- }
- }
- };
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_viewsite);
-
- final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
- toolbar.setNavigationOnClickListener(view -> finish());
- toolbar.inflateMenu(R.menu.menu_viewsite);
- toolbar.setOnMenuItemClickListener(this);
-
- iconStatus = (StatusImageView) findViewById(R.id.iconStatus);
- inputName = (EditText) findViewById(R.id.inputName);
- inputUrl = (EditText) findViewById(R.id.inputUrl);
- textUrlWarning = (TextView) findViewById(R.id.textUrlWarning);
- inputCheckInterval = (EditText) findViewById(R.id.checkIntervalInput);
- checkIntervalSpinner = (Spinner) findViewById(R.id.checkIntervalSpinner);
- textLastCheckResult = (TextView) findViewById(R.id.textLastCheckResult);
- textNextCheck = (TextView) findViewById(R.id.textNextCheck);
- responseValidationSpinner = (Spinner) findViewById(R.id.responseValidationMode);
-
- ArrayAdapter intervalOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner,
- getResources().getStringArray(R.array.interval_options));
- intervalOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown);
- checkIntervalSpinner.setAdapter(intervalOptionsAdapter);
-
- inputUrl.setOnFocusChangeListener((view, hasFocus) -> {
- if (!hasFocus) {
- final String inputStr = inputUrl.getText().toString().trim();
- if (inputStr.isEmpty()) return;
- final Uri uri = Uri.parse(inputStr);
- if (uri.getScheme() == null) {
- inputUrl.setText("http://" + inputStr);
- textUrlWarning.setVisibility(View.GONE);
- } else if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
- textUrlWarning.setVisibility(View.VISIBLE);
- textUrlWarning.setText(R.string.warning_http_url);
- } else {
- textUrlWarning.setVisibility(View.GONE);
- }
- }
- });
-
- ArrayAdapter validationOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner,
- getResources().getStringArray(R.array.response_validation_options));
- validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown);
- responseValidationSpinner.setAdapter(validationOptionsAdapter);
- responseValidationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
- @Override
- public void onItemSelected(AdapterView> adapterView, View view, int i, long l) {
- final View searchTerm = findViewById(R.id.responseValidationSearchTerm);
- final View javascript = findViewById(R.id.responseValidationScript);
- final TextView modeDesc = (TextView) findViewById(R.id.validationModeDescription);
-
- searchTerm.setVisibility(i == 1 ? View.VISIBLE : View.GONE);
- javascript.setVisibility(i == 2 ? View.VISIBLE : View.GONE);
-
- switch (i) {
- case 0:
- modeDesc.setText(R.string.validation_mode_status_desc);
- break;
- case 1:
- modeDesc.setText(R.string.validation_mode_term_desc);
- break;
- case 2:
- modeDesc.setText(R.string.validation_mode_javascript_desc);
- break;
- }
- }
-
- @Override
- public void onNothingSelected(AdapterView> adapterView) {
- }
- });
-
- mModel = (ServerModel) getIntent().getSerializableExtra("model");
- update();
-
- Bridge.config()
- .defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)");
- }
-
- @Override
- protected void onNewIntent(Intent intent) {
- super.onNewIntent(intent);
- if (intent != null && intent.hasExtra("model")) {
- mModel = (ServerModel) intent.getSerializableExtra("model");
- update();
- }
- }
-
- @SuppressLint({"SetTextI18n", "SwitchIntDef"})
- private void update() {
- final SimpleDateFormat df = new SimpleDateFormat("MMMM dd, hh:mm:ss a", Locale.getDefault());
-
- iconStatus.setStatus(mModel.status);
- inputName.setText(mModel.name);
- inputUrl.setText(mModel.url);
-
- if (mModel.lastCheck == 0) {
- textLastCheckResult.setText(R.string.none);
- } else {
- switch (mModel.status) {
- case ServerStatus.CHECKING:
- textLastCheckResult.setText(R.string.checking_status);
- break;
- case ServerStatus.ERROR:
- textLastCheckResult.setText(mModel.reason);
- break;
- case ServerStatus.OK:
- textLastCheckResult.setText(R.string.everything_checks_out);
- break;
- case ServerStatus.WAITING:
- textLastCheckResult.setText(R.string.waiting);
- break;
- }
- }
-
- if (mModel.checkInterval == 0) {
- textNextCheck.setText(R.string.none_turned_off);
- inputCheckInterval.setText("");
- checkIntervalSpinner.setSelection(0);
- } else {
- long lastCheck = mModel.lastCheck;
- if (lastCheck == 0) lastCheck = System.currentTimeMillis();
- textNextCheck.setText(df.format(new Date(lastCheck + mModel.checkInterval)));
-
- if (mModel.checkInterval >= TimeUtil.WEEK) {
- inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.WEEK))));
- checkIntervalSpinner.setSelection(3);
- } else if (mModel.checkInterval >= TimeUtil.DAY) {
- inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.DAY))));
- checkIntervalSpinner.setSelection(2);
- } else if (mModel.checkInterval >= TimeUtil.HOUR) {
- inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.HOUR))));
- checkIntervalSpinner.setSelection(1);
- } else if (mModel.checkInterval >= TimeUtil.MINUTE) {
- inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.MINUTE))));
- checkIntervalSpinner.setSelection(0);
- } else {
- inputCheckInterval.setText("0");
- checkIntervalSpinner.setSelection(0);
- }
- }
-
- responseValidationSpinner.setSelection(mModel.validationMode - 1);
- switch (mModel.validationMode) {
- case ValidationMode.TERM_SEARCH:
- ((TextView) findViewById(R.id.responseValidationSearchTerm)).setText(mModel.validationContent);
- break;
- case ValidationMode.JAVASCRIPT:
- ((TextView) findViewById(R.id.responseValidationScriptInput)).setText(mModel.validationContent);
- break;
- }
-
- findViewById(R.id.doneBtn).setOnClickListener(this);
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- try {
- final IntentFilter filter = new IntentFilter();
- filter.addAction(CheckService.ACTION_CHECK_UPDATE);
- // filter.addAction(CheckService.ACTION_RUNNING);
- registerReceiver(mReceiver, filter);
- } catch (Throwable t) {
- t.printStackTrace();
- }
- }
-
- @Override
- protected void onPause() {
- super.onPause();
- try {
- unregisterReceiver(mReceiver);
- } catch (Throwable t) {
- t.printStackTrace();
- }
- }
-
- private void performSave(boolean withValidation) {
- mModel.name = inputName.getText().toString().trim();
- mModel.url = inputUrl.getText().toString().trim();
- mModel.status = ServerStatus.WAITING;
-
- if (withValidation && mModel.name.isEmpty()) {
- inputName.setError(getString(R.string.please_enter_name));
- return;
- } else {
- inputName.setError(null);
- }
-
- if (withValidation && mModel.url.isEmpty()) {
- inputUrl.setError(getString(R.string.please_enter_url));
- return;
- } else {
- inputUrl.setError(null);
- if (withValidation && !Patterns.WEB_URL.matcher(mModel.url).find()) {
- inputUrl.setError(getString(R.string.please_enter_valid_url));
- return;
- } else {
- final Uri uri = Uri.parse(mModel.url);
- if (uri.getScheme() == null)
- mModel.url = "http://" + mModel.url;
- }
- }
-
- String intervalStr = inputCheckInterval.getText().toString().trim();
- if (intervalStr.isEmpty()) intervalStr = "0";
- mModel.checkInterval = Integer.parseInt(intervalStr);
-
- switch (checkIntervalSpinner.getSelectedItemPosition()) {
- case 0: // minutes
- mModel.checkInterval *= (60 * 1000);
- break;
- case 1: // hours
- mModel.checkInterval *= (60 * 60 * 1000);
- break;
- case 2: // days
- mModel.checkInterval *= (60 * 60 * 24 * 1000);
- break;
- default: // weeks
- mModel.checkInterval *= (60 * 60 * 24 * 7 * 1000);
- break;
- }
-
- mModel.lastCheck = System.currentTimeMillis() - mModel.checkInterval;
-
- switch (responseValidationSpinner.getSelectedItemPosition()) {
- case 0:
- mModel.validationMode = ValidationMode.STATUS_CODE;
- mModel.validationContent = null;
- break;
- case 1:
- mModel.validationMode = ValidationMode.TERM_SEARCH;
- mModel.validationContent = ((EditText) findViewById(R.id.responseValidationSearchTerm)).getText().toString().trim();
- break;
- case 2:
- mModel.validationMode = ValidationMode.JAVASCRIPT;
- mModel.validationContent = ((EditText) findViewById(R.id.responseValidationScriptInput)).getText().toString().trim();
- break;
- }
-
- final Inquiry inq = Inquiry.newInstance(this, MainActivity.DB_NAME)
- .build(false);
- //noinspection CheckResult
- inq.update(MainActivity.SITES_TABLE_NAME, ServerModel.class)
- .where("_id = ?", mModel.id)
- .values(mModel)
- .run();
- inq.destroyInstance();
- }
-
- // Save button
- @Override
- public void onClick(View view) {
- performSave(true);
- setResult(RESULT_OK, new Intent().putExtra("model", mModel));
- finish();
- }
-
- @Override
- public boolean onMenuItemClick(MenuItem item) {
- switch (item.getItemId()) {
- case R.id.refresh:
- performSave(false);
- MainActivity.checkSite(this, mModel);
- return true;
- case R.id.remove:
- MainActivity.removeSite(this, mModel, this::finish);
- return true;
- }
- return false;
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt
new file mode 100644
index 0000000..e15a29f
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt
@@ -0,0 +1,235 @@
+/**
+ * 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 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.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.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
+import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode
+import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
+import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout
+import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout
+import kotlinx.android.synthetic.main.activity_addsite.scrollView
+import kotlinx.android.synthetic.main.activity_addsite.sslCertificateBrowse
+import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput
+import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
+import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
+import kotlinx.android.synthetic.main.include_app_bar.toolbar
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
+import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
+
+/** @author Aidan Follestad (@afollestad) */
+class AddSiteActivity : DarkModeSwitchActivity() {
+ companion object {
+ private const val SELECT_CERT_FILE_RQ = 23
+ }
+
+ private val viewModel by viewModel()
+ private lateinit var validationForm: Form
+
+ @SuppressLint("SetTextI18n")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_addsite)
+ setupUi()
+ setupValidation()
+
+ lifecycle.addObserver(viewModel)
+
+ // Populate view model with initial data
+ val model = intent.getSerializableExtra(KEY_SITE) as? Site
+ model?.let { viewModel.prePopulateFromModel(model) }
+
+ // Loading
+ loadingProgress.observe(this, viewModel.onIsLoading())
+
+ // Name
+ inputName.attachLiveData(this, viewModel.name)
+
+ // Tags
+ inputTags.attachLiveData(this, viewModel.tags)
+
+ // Url
+ inputUrl.attachLiveData(this, viewModel.url)
+ viewModel.onUrlWarningVisibility()
+ .toViewVisibility(this, textUrlWarning)
+
+ // Timeout
+ responseTimeoutInput.attachLiveData(this, viewModel.timeout)
+
+ // Validation mode
+ responseValidationMode.attachLiveData(
+ lifecycleOwner = this,
+ data = viewModel.validationMode,
+ outTransformer = { ValidationMode.fromIndex(it) },
+ inTransformer = { it.toIndex() }
+ )
+ viewModel.onValidationModeDescription()
+ .toViewText(this, validationModeDescription)
+
+ // Validation search term
+ responseValidationSearchTerm.attachLiveData(
+ lifecycleOwner = this,
+ data = viewModel.validationSearchTerm,
+ pullInChanges = false
+ )
+ viewModel.onValidationSearchTermVisibility()
+ .toViewVisibility(this, responseValidationSearchTerm)
+
+ // SSL certificate
+ sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
+ viewModel.certificateUri.distinct()
+ .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
+
+ // 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() }
+ }
+
+ val validationOptionsAdapter = ArrayAdapter(
+ this,
+ R.layout.list_item_spinner,
+ resources.getStringArray(R.array.response_validation_options)
+ )
+ validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
+ responseValidationMode.adapter = validationOptionsAdapter
+
+ scrollView.onScroll {
+ appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
+ appToolbar.dimenFloat(R.dimen.default_elevation)
+ } else {
+ 0f
+ }
+ }
+
+ // SSL certificate
+ sslCertificateBrowse.setOnClickListener {
+ val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
+ addCategory(CATEGORY_OPENABLE)
+ type = "*/*"
+ }
+ startActivityForResult(intent, SELECT_CERT_FILE_RQ)
+ }
+ }
+
+ private fun setupValidation() {
+ validationForm = form {
+ input(inputName, name = "Name") {
+ isNotEmpty().description(R.string.please_enter_name)
+ }
+ input(inputUrl, name = "URL") {
+ isNotEmpty().description(R.string.please_enter_url)
+ isUrl().description(R.string.please_enter_valid_url)
+ }
+ input(responseTimeoutInput, name = "Timeout", optional = true) {
+ isNumber().greaterThan(0)
+ .description(R.string.please_enter_networkTimeout)
+ }
+ input(responseValidationSearchTerm, name = "Search term") {
+ conditional(responseValidationSearchTerm.isVisibleCondition()) {
+ isNotEmpty().description(R.string.please_enter_search_term)
+ }
+ }
+ input(sslCertificateInput, name = "Certificate Path", optional = true) {
+ isUri().hasScheme("file", "content")
+ .that { it.host != null }
+ .description(R.string.please_enter_validCertUri)
+ }
+ submitWith(toolbar.menu, R.id.commit) {
+ viewModel.commit {
+ setResult(RESULT_OK)
+ finish()
+ }
+ }
+ }
+
+ // Validation script
+ scriptInputLayout.attach(
+ codeData = viewModel.validationScript,
+ visibility = viewModel.onValidationScriptVisibility(),
+ form = validationForm
+ )
+
+ // Check interval
+ checkIntervalLayout.attach(
+ valueData = viewModel.checkIntervalValue,
+ multiplierData = viewModel.checkIntervalUnit,
+ form = validationForm
+ )
+
+ // Retry Policy
+ retryPolicyLayout.attach(
+ timesData = viewModel.retryPolicyTimes,
+ minutesData = viewModel.retryPolicyMinutes,
+ form = validationForm
+ )
+ }
+
+ override fun onResume() {
+ super.onResume()
+ appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
+ appToolbar.dimenFloat(R.dimen.default_elevation)
+ } else {
+ 0f
+ }
+ }
+
+ override fun onActivityResult(
+ requestCode: Int,
+ resultCode: Int,
+ resultData: Intent?
+ ) {
+ super.onActivityResult(requestCode, resultCode, resultData)
+ if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
+ sslCertificateInput.setText(resultData?.data?.toString() ?: "")
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt
new file mode 100644
index 0000000..d7d8ed5
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt
@@ -0,0 +1,187 @@
+/**
+ * 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 androidx.annotation.CheckResult
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.PRIVATE
+import androidx.lifecycle.Lifecycle.Event.ON_START
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.OnLifecycleEvent
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.AppDatabase
+import com.afollestad.nocknock.data.model.Header
+import com.afollestad.nocknock.data.model.RetryPolicy
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.SiteSettings
+import com.afollestad.nocknock.data.model.Status.WAITING
+import com.afollestad.nocknock.data.model.ValidationMode
+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.data.putSite
+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 kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import java.lang.System.currentTimeMillis
+
+/** @author Aidan Follestad (@afollestad) */
+class AddSiteViewModel(
+ private val database: AppDatabase,
+ private val validationManager: ValidationExecutor,
+ mainDispatcher: CoroutineDispatcher,
+ private val ioDispatcher: CoroutineDispatcher
+) : ScopedViewModel(mainDispatcher), LifecycleObserver {
+
+ // Public properties
+ val name = MutableLiveData()
+ val tags = MutableLiveData()
+ val url = MutableLiveData()
+ val timeout = MutableLiveData()
+ val validationMode = MutableLiveData()
+ val validationSearchTerm = MutableLiveData()
+ val validationScript = MutableLiveData()
+ val checkIntervalValue = MutableLiveData()
+ val checkIntervalUnit = MutableLiveData()
+ val retryPolicyTimes = MutableLiveData()
+ val retryPolicyMinutes = MutableLiveData()
+ val headers = MutableLiveData>()
+ val certificateUri = MutableLiveData()
+
+ @OnLifecycleEvent(ON_START)
+ fun setDefaults() {
+ timeout.value = 10000
+ validationMode.value = STATUS_CODE
+ checkIntervalValue.value = 0
+ checkIntervalUnit.value = MINUTE
+ retryPolicyMinutes.value = 0
+ retryPolicyMinutes.value = 0
+ tags.value = ""
+ headers.value = emptyList()
+ }
+
+ private val isLoading = MutableLiveData()
+
+ @CheckResult fun onIsLoading(): LiveData = isLoading
+
+ @CheckResult fun onUrlWarningVisibility(): LiveData {
+ return url.map {
+ val parsed = HttpUrl.parse(it)
+ return@map it.isNotEmpty() && parsed == null
+ }
+ }
+
+ @CheckResult fun onValidationModeDescription(): LiveData {
+ return validationMode.map {
+ when (it!!) {
+ STATUS_CODE -> R.string.validation_mode_status_desc
+ TERM_SEARCH -> R.string.validation_mode_term_desc
+ JAVASCRIPT -> R.string.validation_mode_javascript_desc
+ }
+ }
+ }
+
+ @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
+
+ @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
+
+ // Actions
+ fun commit(done: () -> Unit) {
+ scope.launch {
+ val newModel = generateDbModel() ?: return@launch
+ isLoading.value = true
+
+ val storedModel = withContext(ioDispatcher) {
+ database.putSite(newModel)
+ }
+ validationManager.scheduleValidation(
+ site = storedModel,
+ rightNow = true,
+ cancelPrevious = true
+ )
+
+ isLoading.value = false
+ done()
+ }
+ }
+
+ // Utilities
+ @VisibleForTesting(otherwise = PRIVATE)
+ fun getCheckIntervalMs(): Long {
+ val value = checkIntervalValue.value ?: return 0
+ val unit = checkIntervalUnit.value ?: return 0
+ return value * unit
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ fun getValidationArgs(): String? {
+ return when (validationMode.value) {
+ TERM_SEARCH -> validationSearchTerm.value
+ JAVASCRIPT -> validationScript.value
+ else -> null
+ }
+ }
+
+ private fun generateDbModel(): Site? {
+ 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,
+ disabled = false,
+ certificate = certificateUri.value?.toString()
+ )
+
+ val newLastResult = ValidationResult(
+ timestampMs = currentTimeMillis(),
+ status = WAITING,
+ reason = null
+ )
+
+ val retryPolicyTimes = retryPolicyTimes.value ?: 0
+ val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
+ val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
+ RetryPolicy(
+ count = retryPolicyTimes,
+ minutes = retryPolicyMinutes
+ )
+ } else {
+ null
+ }
+
+ return Site(
+ id = 0,
+ name = name.value!!.trim(),
+ url = url.value!!.trim(),
+ tags = cleanedTags,
+ settings = newSettings,
+ lastResult = newLastResult,
+ retryPolicy = newRetryPolicy,
+ headers = headers.value ?: emptyList()
+ )
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt
new file mode 100644
index 0000000..c524555
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt
@@ -0,0 +1,99 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.addsite
+
+import com.afollestad.nocknock.data.model.RetryPolicy
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
+import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
+import com.afollestad.nocknock.utilities.ext.DAY
+import com.afollestad.nocknock.utilities.ext.HOUR
+import com.afollestad.nocknock.utilities.ext.MINUTE
+import com.afollestad.nocknock.utilities.ext.WEEK
+import kotlin.math.ceil
+
+fun AddSiteViewModel.prePopulateFromModel(site: Site) {
+ val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!")
+
+ name.value = site.name
+ tags.value = site.tags
+ url.value = site.url
+ timeout.value = settings.networkTimeout
+
+ validationMode.value = settings.validationMode
+ when (settings.validationMode) {
+ TERM_SEARCH -> {
+ validationSearchTerm.value = settings.validationArgs
+ validationScript.value = null
+ }
+ JAVASCRIPT -> {
+ validationSearchTerm.value = null
+ validationScript.value = settings.validationArgs
+ }
+ else -> {
+ validationSearchTerm.value = null
+ validationScript.value = null
+ }
+ }
+
+ setCheckInterval(settings.validationIntervalMs)
+ setRetryPolicy(site.retryPolicy)
+ headers.value = site.headers
+}
+
+private fun AddSiteViewModel.setCheckInterval(interval: Long) {
+ when {
+ interval >= WEEK -> {
+ checkIntervalValue.value =
+ getIntervalFromUnit(interval, WEEK)
+ checkIntervalUnit.value = WEEK
+ }
+ interval >= DAY -> {
+ checkIntervalValue.value =
+ getIntervalFromUnit(interval, DAY)
+ checkIntervalUnit.value = DAY
+ }
+ interval >= HOUR -> {
+ checkIntervalValue.value =
+ getIntervalFromUnit(interval, HOUR)
+ checkIntervalUnit.value = HOUR
+ }
+ interval >= MINUTE -> {
+ checkIntervalValue.value =
+ getIntervalFromUnit(interval, MINUTE)
+ checkIntervalUnit.value = MINUTE
+ }
+ else -> {
+ checkIntervalValue.value = 0
+ checkIntervalUnit.value = MINUTE
+ }
+ }
+}
+
+private fun AddSiteViewModel.setRetryPolicy(policy: RetryPolicy?) {
+ if (policy == null) return
+ retryPolicyTimes.value = policy.count
+ retryPolicyMinutes.value = policy.minutes
+}
+
+private fun getIntervalFromUnit(
+ millis: Long,
+ unit: Long
+): Int {
+ val intervalFloat = millis.toFloat()
+ val byFloat = unit.toFloat()
+ return ceil(intervalFloat / byFloat).toInt()
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt
new file mode 100644
index 0000000..aec76d8
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt
@@ -0,0 +1,149 @@
+/**
+ * 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.main
+
+import android.content.Intent
+import android.os.Bundle
+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.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.viewcomponents.livedata.toViewVisibility
+import kotlinx.android.synthetic.main.activity_main.fab
+import kotlinx.android.synthetic.main.activity_main.list
+import kotlinx.android.synthetic.main.activity_main.loadingProgress
+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() {
+
+ private val notificationManager by inject()
+ private val intentProvider by inject()
+
+ internal val viewModel by viewModel()
+
+ private lateinit var siteAdapter: SiteAdapter
+ private lateinit var tagAdapter: TagAdapter
+
+ private val statusUpdateReceiver by lazy {
+ StatusUpdateIntentReceiver(application, intentProvider) {
+ viewModel.postSiteUpdate(it)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+ setupUi()
+
+ notificationManager.createChannels()
+
+ lifecycle.run {
+ addObserver(viewModel)
+ addObserver(statusUpdateReceiver)
+ }
+
+ viewModel.onSites()
+ .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)
+ }
+
+ private fun setupUi() {
+ toolbar.run {
+ inflateMenu(R.menu.menu_main)
+ menu.findItem(R.id.dark_mode)
+ .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()
+ }
+ 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() }
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ intent?.let(::processIntent)
+ }
+
+ private fun onSiteSelected(
+ model: Site,
+ longClick: Boolean
+ ) {
+ if (longClick) {
+ MaterialDialog(this).show {
+ title(R.string.options)
+ listItems(R.array.site_long_options) { _, i, _ ->
+ when (i) {
+ 0 -> viewModel.refreshSite(model)
+ 1 -> addSiteForDuplication(model)
+ 2 -> maybeRemoveSite(model)
+ }
+ }
+ }
+ } else {
+ viewSite(model)
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt
new file mode 100644
index 0000000..e11ca08
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt
@@ -0,0 +1,73 @@
+/**
+ * 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.main
+
+import android.content.Intent
+import com.afollestad.materialdialogs.MaterialDialog
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.toHtml
+import com.afollestad.nocknock.ui.addsite.AddSiteActivity
+import com.afollestad.nocknock.ui.viewsite.KEY_SITE
+import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
+import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL
+
+internal const val VIEW_SITE_RQ = 6923
+internal const val ADD_SITE_RQ = 6969
+
+// ADD
+
+internal fun MainActivity.addSite() {
+ 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)
+}
+
+private fun MainActivity.intentToView(model: Site) =
+ Intent(this, ViewSiteActivity::class.java).apply {
+ putExtra(KEY_SITE, model)
+ }
+
+// MISC
+
+internal fun MainActivity.maybeRemoveSite(model: Site) {
+ MaterialDialog(this).show {
+ title(R.string.remove_site)
+ message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
+ positiveButton(R.string.remove) { viewModel.removeSite(model) }
+ negativeButton(android.R.string.cancel)
+ }
+}
+
+internal fun MainActivity.processIntent(intent: Intent) {
+ if (intent.hasExtra(KEY_VIEW_NOTIFICATION_MODEL)) {
+ val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as Site
+ viewSite(model)
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt
new file mode 100644
index 0000000..bad64fc
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt
@@ -0,0 +1,157 @@
+/**
+ * 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.main
+
+import androidx.annotation.CheckResult
+import androidx.lifecycle.Lifecycle.Event.ON_RESUME
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.OnLifecycleEvent
+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.ValidationExecutor
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.ui.ScopedViewModel
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** @author Aidan Follestad (@afollestad) */
+class MainViewModel(
+ private val database: AppDatabase,
+ private val notificationManager: NockNotificationManager,
+ private val validationManager: ValidationExecutor,
+ mainDispatcher: CoroutineDispatcher,
+ private val ioDispatcher: CoroutineDispatcher
+) : ScopedViewModel(mainDispatcher), LifecycleObserver {
+
+ private val sites = MutableLiveData>()
+ private val isLoading = MutableLiveData()
+ private val emptyTextVisibility = MutableLiveData()
+ private val tags = MutableLiveData>()
+ private val tagsListVisibility = MutableLiveData()
+
+ @CheckResult fun onSites(): LiveData> = sites
+
+ @CheckResult fun onIsLoading(): LiveData = isLoading
+
+ @CheckResult fun onEmptyTextVisibility(): LiveData = emptyTextVisibility
+
+ @CheckResult fun onTags(): LiveData> = tags
+
+ @CheckResult fun onTagsListVisibility(): LiveData = tagsListVisibility
+
+ @OnLifecycleEvent(ON_RESUME)
+ fun onResume() = loadSites(emptyList())
+
+ fun onTagSelection(tags: List) = loadSites(tags)
+
+ fun postSiteUpdate(model: Site) {
+ val currentSites = sites.value ?: return
+ val index = currentSites.indexOfFirst { it.id == model.id }
+ if (index == -1) return
+ sites.value = currentSites.toMutableList()
+ .apply {
+ this[index] = model
+ }
+ }
+
+ fun refreshSite(model: Site) {
+ validationManager.scheduleValidation(
+ site = model,
+ rightNow = true,
+ cancelPrevious = true
+ )
+ }
+
+ fun removeSite(model: Site) {
+ validationManager.cancelScheduledValidation(model)
+ notificationManager.cancelStatusNotification(model)
+
+ scope.launch {
+ isLoading.value = true
+ withContext(ioDispatcher) { database.deleteSite(model) }
+
+ val currentSites = sites.value ?: return@launch
+ val index = currentSites.indexOfFirst { it.id == model.id }
+ isLoading.value = false
+ if (index == -1) return@launch
+
+ val newSitesList = currentSites.toMutableList()
+ .apply {
+ removeAt(index)
+ }
+ sites.value = newSitesList
+ emptyTextVisibility.value = newSitesList.isEmpty()
+ }
+ }
+
+ private fun loadSites(forTags: List) {
+ scope.launch {
+ notificationManager.cancelStatusNotifications()
+ emptyTextVisibility.value = false
+ isLoading.value = true
+
+ 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.ensureScheduledValidations()
+ }
+ }
+
+ private fun pullOutTags(sites: List): List {
+ return mutableListOf().apply {
+ for (site in sites) {
+ val splitTags = site.tags.toLowerCase()
+ .split(',')
+ splitTags
+ .filter { it.isNotEmpty() }
+ .forEach { tag ->
+ if (!this.contains(tag)) {
+ this.add(tag)
+ }
+ }
+ }
+ sort()
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt
new file mode 100644
index 0000000..2aa312c
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt
@@ -0,0 +1,291 @@
+/**
+ * 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.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
+import com.afollestad.nocknock.R
+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.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.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
+import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
+import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
+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
+import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
+import kotlinx.android.synthetic.main.include_app_bar.toolbar
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
+import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
+
+/** @author Aidan Follestad (@afollestad) */
+class ViewSiteActivity : DarkModeSwitchActivity() {
+ companion object {
+ private const val SELECT_CERT_FILE_RQ = 23
+ }
+
+ internal val viewModel by viewModel()
+ private lateinit var validationForm: Form
+
+ private val intentProvider by inject()
+ private val statusUpdateReceiver by lazy {
+ StatusUpdateIntentReceiver(application, intentProvider) {
+ viewModel.setModel(it)
+ }
+ }
+
+ @SuppressLint("SetTextI18n")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_viewsite)
+
+ // 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())
+
+ // Status
+ viewModel.status.observe(this, Observer {
+ iconStatus.setStatus(it)
+ invalidateMenuForStatus(it)
+ })
+
+ // Name
+ inputName.attachLiveData(this, viewModel.name)
+
+ // Tags
+ inputTags.attachLiveData(this, viewModel.tags)
+
+ // Url
+ inputUrl.attachLiveData(this, viewModel.url)
+ viewModel.onUrlWarningVisibility()
+ .toViewVisibility(this, textUrlWarning)
+
+ // Timeout
+ responseTimeoutInput.attachLiveData(this, viewModel.timeout)
+
+ // Validation mode
+ responseValidationMode.attachLiveData(
+ lifecycleOwner = this,
+ data = viewModel.validationMode,
+ outTransformer = { ValidationMode.fromIndex(it) },
+ inTransformer = { it.toIndex() }
+ )
+ viewModel.onValidationModeDescription()
+ .toViewText(this, validationModeDescription)
+
+ // Validation search term
+ responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
+ viewModel.onValidationSearchTermVisibility()
+ .toViewVisibility(this, responseValidationSearchTerm)
+
+ // SSL certificate
+ sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
+ viewModel.certificateUri.distinct()
+ .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
+
+ // Headers
+ headersLayout.attach(viewModel.headers)
+
+ // Last/next check
+ viewModel.onLastCheckResultText()
+ .toViewText(this, textLastCheckResult)
+ viewModel.onNextCheckText()
+ .toViewText(this, textNextCheck)
+ }
+
+ private fun setupUi() {
+ 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 {
+ when (it.itemId) {
+ R.id.remove -> maybeRemoveSite()
+ R.id.disableChecks -> maybeDisableChecks()
+ }
+ true
+ }
+ }
+
+ scrollView.onScroll {
+ appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
+ appToolbar.dimenFloat(R.dimen.default_elevation)
+ } else {
+ 0f
+ }
+ }
+
+ val validationOptionsAdapter = ArrayAdapter(
+ this,
+ R.layout.list_item_spinner,
+ resources.getStringArray(R.array.response_validation_options)
+ )
+ validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
+ responseValidationMode.adapter = validationOptionsAdapter
+
+ // Disabled button
+ viewModel.onDisableChecksVisibility()
+ .observe(this, Observer {
+ toolbar.menu.findItem(R.id.disableChecks)
+ .isVisible = it
+ })
+
+ // Done item text
+ viewModel.onDoneButtonText()
+ .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() ?: "")
+ }
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ if (intent != null && intent.hasExtra(KEY_SITE)) {
+ val newModel = intent.getSerializableExtra(KEY_SITE) as Site
+ viewModel.setModel(newModel)
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt
new file mode 100644
index 0000000..ab0a6ea
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt
@@ -0,0 +1,64 @@
+/**
+ * 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.viewsite
+
+import android.widget.ImageView
+import com.afollestad.materialdialogs.MaterialDialog
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.model.Status
+import com.afollestad.nocknock.data.model.isPending
+import com.afollestad.nocknock.toHtml
+import com.afollestad.nocknock.utilities.ext.animateRotation
+import kotlinx.android.synthetic.main.include_app_bar.toolbar
+
+const val KEY_SITE = "site_model"
+
+internal fun ViewSiteActivity.maybeRemoveSite() {
+ val model = viewModel.site
+ MaterialDialog(this).show {
+ title(R.string.remove_site)
+ message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
+ positiveButton(R.string.remove) {
+ viewModel.removeSite { finish() }
+ }
+ negativeButton(android.R.string.cancel)
+ }
+}
+
+internal fun ViewSiteActivity.maybeDisableChecks() {
+ val model = viewModel.site
+ MaterialDialog(this).show {
+ title(R.string.disable_automatic_checks)
+ message(
+ text = context.getString(R.string.disable_automatic_checks_prompt, model.name).toHtml()
+ )
+ positiveButton(R.string.disable) { viewModel.disableSite() }
+ negativeButton(android.R.string.cancel)
+ }
+}
+
+internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) {
+ val refreshIcon = toolbar.menu.findItem(R.id.refresh)
+ .actionView as ImageView
+ if (status.isPending()) {
+ refreshIcon.animateRotation()
+ } else {
+ refreshIcon.run {
+ animate().cancel()
+ rotation = 0f
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt
new file mode 100644
index 0000000..b5c9f93
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt
@@ -0,0 +1,268 @@
+/**
+ * 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.viewsite
+
+import androidx.annotation.CheckResult
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.PRIVATE
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.AppDatabase
+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
+import com.afollestad.nocknock.data.model.ValidationMode
+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.data.model.textRes
+import com.afollestad.nocknock.data.updateSite
+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 kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import java.lang.System.currentTimeMillis
+
+/** @author Aidan Follestad (@afollestad) */
+class ViewSiteViewModel(
+ private val stringProvider: StringProvider,
+ private val database: AppDatabase,
+ private val notificationManager: NockNotificationManager,
+ private val validationManager: ValidationExecutor,
+ mainDispatcher: CoroutineDispatcher,
+ private val ioDispatcher: CoroutineDispatcher
+) : ScopedViewModel(mainDispatcher), LifecycleObserver {
+
+ lateinit var site: Site
+
+ // Public properties
+ val status = MutableLiveData()
+ val name = MutableLiveData()
+ val tags = MutableLiveData()
+ val url = MutableLiveData()
+ val timeout = MutableLiveData()
+ val validationMode = MutableLiveData()
+ val validationSearchTerm = MutableLiveData()
+ val validationScript = MutableLiveData()
+ val checkIntervalValue = MutableLiveData()
+ val checkIntervalUnit = MutableLiveData()
+ val retryPolicyTimes = MutableLiveData()
+ val retryPolicyMinutes = MutableLiveData()
+ val headers = MutableLiveData>()
+ val certificateUri = MutableLiveData()
+ internal val disabled = MutableLiveData()
+ internal val lastResult = MutableLiveData()
+
+ private val isLoading = MutableLiveData()
+
+ @CheckResult fun onIsLoading(): LiveData = isLoading
+
+ @CheckResult fun onUrlWarningVisibility(): LiveData {
+ return url.map {
+ val parsed = HttpUrl.parse(it)
+ return@map it.isNotEmpty() && parsed == null
+ }
+ }
+
+ @CheckResult fun onValidationModeDescription(): LiveData {
+ return validationMode.map {
+ when (it!!) {
+ STATUS_CODE -> R.string.validation_mode_status_desc
+ TERM_SEARCH -> R.string.validation_mode_term_desc
+ JAVASCRIPT -> R.string.validation_mode_javascript_desc
+ }
+ }
+ }
+
+ @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
+
+ @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
+
+ @CheckResult fun onDisableChecksVisibility(): LiveData = disabled.map { !it }
+
+ @CheckResult fun onDoneButtonText(): LiveData =
+ disabled.map {
+ if (it) R.string.renable_and_save_changes
+ else R.string.save_changes
+ }
+
+ @CheckResult fun onLastCheckResultText(): LiveData = lastResult.map {
+ if (it == null) {
+ stringProvider.get(R.string.none)
+ } else {
+ val statusText = it.status.textRes()
+ if (statusText == 0) {
+ it.reason
+ } else {
+ stringProvider.get(statusText)
+ }
+ }
+ }
+
+ @CheckResult fun onNextCheckText(): LiveData {
+ return zip(disabled, lastResult)
+ .map {
+ val disabled = it.first
+ val lastResult = it.second
+ if (disabled) {
+ stringProvider.get(R.string.auto_checks_disabled)
+ } else {
+ val lastCheck = lastResult?.timestampMs ?: currentTimeMillis()
+ (lastCheck + getCheckIntervalMs()).formatDate()
+ }
+ }
+ }
+
+ // Actions
+ fun commit(done: () -> Unit) {
+ scope.launch {
+ val updatedModel = getUpdatedDbModel() ?: return@launch
+ isLoading.value = true
+
+ withContext(ioDispatcher) {
+ database.updateSite(updatedModel)
+ }
+ validationManager.scheduleValidation(
+ site = updatedModel,
+ rightNow = true,
+ cancelPrevious = true
+ )
+
+ isLoading.value = false
+ done()
+ }
+ }
+
+ fun checkNow() {
+ val checkModel = site.withStatus(
+ status = WAITING
+ )
+ setModel(checkModel)
+ validationManager.scheduleValidation(
+ site = checkModel,
+ rightNow = true,
+ cancelPrevious = true
+ )
+ }
+
+ fun removeSite(done: () -> Unit) {
+ validationManager.cancelScheduledValidation(site)
+ notificationManager.cancelStatusNotification(site)
+
+ scope.launch {
+ isLoading.value = true
+ withContext(ioDispatcher) {
+ database.deleteSite(site)
+ }
+ isLoading.value = false
+ done()
+ }
+ }
+
+ fun disableSite() {
+ validationManager.cancelScheduledValidation(site)
+ notificationManager.cancelStatusNotification(site)
+
+ scope.launch {
+ isLoading.value = true
+ val newModel = site.copy(
+ settings = site.settings!!.copy(
+ disabled = true
+ )
+ )
+ withContext(ioDispatcher) {
+ database.updateSite(newModel)
+ }
+ isLoading.value = false
+ setModel(newModel)
+ }
+ }
+
+ // Utilities
+ @VisibleForTesting(otherwise = PRIVATE)
+ fun getCheckIntervalMs(): Long {
+ val value = checkIntervalValue.value ?: return 0
+ val unit = checkIntervalUnit.value ?: return 0
+ return value * unit
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ fun getValidationArgs(): String? {
+ return when (validationMode.value) {
+ TERM_SEARCH -> validationSearchTerm.value?.trim()
+ JAVASCRIPT -> validationScript.value?.trim()
+ else -> null
+ }
+ }
+
+ private fun getUpdatedDbModel(): Site? {
+ 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,
+ disabled = false,
+ certificate = certificateUri.value?.toString()
+ )
+
+ val retryPolicyTimes = retryPolicyTimes.value ?: 0
+ val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
+ val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
+ if (site.retryPolicy != null) {
+ // Have existing policy, update it
+ site.retryPolicy!!.copy(
+ count = retryPolicyTimes,
+ minutes = retryPolicyMinutes
+ )
+ } else {
+ // Create new policy
+ RetryPolicy(
+ count = retryPolicyTimes,
+ minutes = retryPolicyMinutes
+ )
+ }
+ } else {
+ // No policy
+ null
+ }
+
+ return site.copy(
+ name = name.value!!.trim(),
+ tags = cleanedTags,
+ url = url.value!!.trim(),
+ settings = newSettings,
+ retryPolicy = retryPolicy,
+ headers = headers.value ?: emptyList()
+ )
+ .withStatus(status = WAITING)
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt
new file mode 100644
index 0000000..800f235
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt
@@ -0,0 +1,110 @@
+/**
+ * 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.viewsite
+
+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
+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 ViewSiteViewModel.setModel(site: Site) {
+ val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!")
+ this.site = site
+
+ status.value = site.lastResult?.status ?: WAITING
+ 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
+ if (settings.certificate == "null") {
+ certificateUri.value = ""
+ } else {
+ certificateUri.value = settings.certificate
+ }
+
+ this.disabled.value = settings.disabled
+ this.lastResult.value = site.lastResult
+}
+
+private fun ViewSiteViewModel.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 ViewSiteViewModel.setRetryPolicy(policy: RetryPolicy?) {
+ if (policy == null) return
+ retryPolicyTimes.value = policy.count
+ retryPolicyMinutes.value = policy.minutes
+}
+
+private fun getIntervalFromUnit(
+ millis: Long,
+ unit: Long
+): Int {
+ val intervalFloat = millis.toFloat()
+ val byFloat = unit.toFloat()
+ return ceil(intervalFloat / byFloat).toInt()
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/util/AlarmUtil.java b/app/src/main/java/com/afollestad/nocknock/util/AlarmUtil.java
deleted file mode 100644
index 7effdb4..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/AlarmUtil.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.afollestad.nocknock.util;
-
-import android.app.AlarmManager;
-import android.app.PendingIntent;
-import android.content.Context;
-import android.content.Intent;
-import android.util.Log;
-
-import com.afollestad.nocknock.api.ServerModel;
-import com.afollestad.nocknock.services.CheckService;
-
-import java.text.SimpleDateFormat;
-import java.util.Date;
-import java.util.Locale;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class AlarmUtil {
-
- private final static int BASE_RQC = 69;
-
- public static PendingIntent getSiteIntent(Context context, ServerModel site) {
- return PendingIntent.getService(context,
- BASE_RQC + (int) site.id,
- new Intent(context, CheckService.class)
- .putExtra(CheckService.MODEL_ID, site.id),
- PendingIntent.FLAG_UPDATE_CURRENT);
- }
-
- private static AlarmManager am(Context context) {
- return (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
- }
-
- public static void cancelSiteChecks(Context context, ServerModel site) {
- PendingIntent pi = getSiteIntent(context, site);
- am(context).cancel(pi);
- }
-
- public static void setSiteChecks(Context context, ServerModel site) {
- cancelSiteChecks(context, site);
- if (site.checkInterval <= 0) return;
- if (site.lastCheck <= 0)
- site.lastCheck = System.currentTimeMillis();
- final long nextCheck = site.lastCheck + site.checkInterval;
- final AlarmManager aMgr = am(context);
- final PendingIntent serviceIntent = getSiteIntent(context, site);
- aMgr.setRepeating(AlarmManager.RTC_WAKEUP, nextCheck, site.checkInterval, serviceIntent);
- final SimpleDateFormat df = new SimpleDateFormat("EEE MMM dd hh:mm:ssa z yyyy", Locale.getDefault());
- Log.d("AlarmUtil", String.format(Locale.getDefault(), "Set site check alarm for %s (%s), check interval: %d, next check: %s",
- site.name, site.url, site.checkInterval, df.format(new Date(nextCheck))));
- }
-
- public static void setSiteChecks(Context context, ServerModel[] sites) {
- if (sites == null || sites.length == 0) return;
- for (ServerModel site : sites)
- setSiteChecks(context, site);
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/afollestad/nocknock/util/JsUtil.java b/app/src/main/java/com/afollestad/nocknock/util/JsUtil.java
deleted file mode 100644
index 160927b..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/JsUtil.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package com.afollestad.nocknock.util;
-
-import android.util.Log;
-
-import org.mozilla.javascript.Context;
-import org.mozilla.javascript.EvaluatorException;
-import org.mozilla.javascript.Function;
-import org.mozilla.javascript.Scriptable;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class JsUtil {
-
- public static String exec(String code, String response) {
- try {
- final String func = String.format(
- "function validate(response) { " +
- "try { " +
- "%s " +
- "} catch(e) { " +
- "return e; " +
- "} " +
- "}", code.replace("\n", " "));
-
- // Every Rhino VM begins with the enter()
- // This Context is not Android's Context
- Context rhino = Context.enter();
-
- // Turn off optimization to make Rhino Android compatible
- rhino.setOptimizationLevel(-1);
- try {
- Scriptable scope = rhino.initStandardObjects();
-
- // Note the forth argument is 1, which means the JavaScript source has
- // been compressed to only one line using something like YUI
- rhino.evaluateString(scope, func, "JavaScript", 1, null);
-
- // Get the functionName defined in JavaScriptCode
- Function jsFunction = (Function) scope.get("validate", scope);
-
- // Call the function with params
- Object jsResult = jsFunction.call(rhino, scope, scope, new Object[]{response});
-
- // Parse the jsResult object to a String
- String result = Context.toString(jsResult);
-
- boolean success = result != null && result.equals("true");
- String message = "The script returned a value other than true!";
- if (!success && result != null && !result.equals("false")) {
- if (result.equals("undefined")) {
- message = "The script did not return or throw anything!";
- } else {
- message = result;
- }
- }
-
- Log.d("JsUtil", "Evaluated to " + message + " (" + success + "): " + code);
- return !success ? message : null;
- } finally {
- Context.exit();
- }
- } catch (EvaluatorException e) {
- return e.getMessage();
-
-
- }
- }
-
- private JsUtil() {
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java b/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java
deleted file mode 100644
index 6cb885b..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.afollestad.nocknock.util;
-
-import android.graphics.Path;
-import android.support.design.widget.FloatingActionButton;
-import android.view.View;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public final class MathUtil {
-
- public static Path bezierCurve(FloatingActionButton fab, View rootView) {
- final int fabCenterX = (int) (fab.getX() + fab.getMeasuredWidth() / 2);
- final int fabCenterY = (int) (fab.getY() + fab.getMeasuredHeight() / 2);
-
- final int endCenterX = (rootView.getMeasuredWidth() / 2) - (fab.getMeasuredWidth() / 2);
- final int endCenterY = (rootView.getMeasuredHeight() / 2) - (fab.getMeasuredHeight() / 2);
-
- final int halfX = (fabCenterX - endCenterX) / 2;
- final int halfY = (fabCenterY - endCenterY) / 2;
- int mControlX = endCenterX + halfX;
- int mControlY = endCenterY + halfY;
- mControlY -= halfY;
- mControlX += halfX;
-
- Path path = new Path();
- path.moveTo(fab.getX(), fab.getY());
- path.quadTo(mControlX, mControlY, endCenterX, endCenterY);
-
- return path;
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/afollestad/nocknock/util/NetworkUtil.java b/app/src/main/java/com/afollestad/nocknock/util/NetworkUtil.java
deleted file mode 100644
index 3680f26..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/NetworkUtil.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.afollestad.nocknock.util;
-
-import android.content.Context;
-import android.net.ConnectivityManager;
-import android.net.NetworkInfo;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class NetworkUtil {
-
- public static boolean hasInternet(Context context) {
- final ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
- final NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
- return activeNetwork != null &&
- activeNetwork.isConnectedOrConnecting();
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/afollestad/nocknock/util/TimeUtil.java b/app/src/main/java/com/afollestad/nocknock/util/TimeUtil.java
deleted file mode 100644
index 3a65b73..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/TimeUtil.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.afollestad.nocknock.util;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class TimeUtil {
-
- public final static long SECOND = 1000;
- public final static long MINUTE = SECOND * 60;
- public final static long HOUR = MINUTE * 60;
- public final static long DAY = HOUR * 24;
- public final static long WEEK = DAY * 7;
- public final static long MONTH = WEEK * 4;
-
- public static String str(long duration) {
- if (duration <= 0) {
- return "";
- } else if (duration >= MONTH) {
- return (int) Math.ceil(((float) duration / (float) MONTH)) + "mo";
- } else if (duration >= WEEK) {
- return (int) Math.ceil(((float) duration / (float) WEEK)) + "w";
- } else if (duration >= DAY) {
- return (int) Math.ceil(((float) duration / (float) DAY)) + "d";
- } else if (duration >= HOUR) {
- return (int) Math.ceil(((float) duration / (float) HOUR)) + "h";
- } else if (duration >= MINUTE) {
- return (int) Math.ceil(((float) duration / (float) MINUTE)) + "m";
- } else {
- return "<1m";
- }
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/views/DividerItemDecoration.java b/app/src/main/java/com/afollestad/nocknock/views/DividerItemDecoration.java
deleted file mode 100644
index cc4396a..0000000
--- a/app/src/main/java/com/afollestad/nocknock/views/DividerItemDecoration.java
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.afollestad.nocknock.views;
-
-import android.content.Context;
-import android.content.res.TypedArray;
-import android.graphics.Canvas;
-import android.graphics.Rect;
-import android.graphics.drawable.Drawable;
-import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
-import android.view.View;
-
-/*
- * Copyright (C) 2014 The Android Open Source Project
- *
- * 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.
- */
-public class DividerItemDecoration extends RecyclerView.ItemDecoration {
-
- private static final int[] ATTRS = new int[]{
- android.R.attr.listDivider
- };
-
- public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;
-
- public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;
-
- private Drawable mDivider;
-
- private int mOrientation;
-
- public DividerItemDecoration(Context context, int orientation) {
- final TypedArray a = context.obtainStyledAttributes(ATTRS);
- mDivider = a.getDrawable(0);
- a.recycle();
- setOrientation(orientation);
- }
-
- public void setOrientation(int orientation) {
- if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
- throw new IllegalArgumentException("invalid orientation");
- }
- mOrientation = orientation;
- }
-
- @Override
- public void onDraw(Canvas c, RecyclerView parent) {
- if (mOrientation == VERTICAL_LIST) {
- drawVertical(c, parent);
- } else {
- drawHorizontal(c, parent);
- }
- }
-
- public void drawVertical(Canvas c, RecyclerView parent) {
- final int left = parent.getPaddingLeft();
- final int right = parent.getWidth() - parent.getPaddingRight();
-
- final int childCount = parent.getChildCount();
- for (int i = 0; i < childCount; i++) {
- final View child = parent.getChildAt(i);
- final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
- .getLayoutParams();
- final int top = child.getBottom() + params.bottomMargin;
- final int bottom = top + mDivider.getIntrinsicHeight();
- mDivider.setBounds(left, top, right, bottom);
- mDivider.draw(c);
- }
- }
-
- public void drawHorizontal(Canvas c, RecyclerView parent) {
- final int top = parent.getPaddingTop();
- final int bottom = parent.getHeight() - parent.getPaddingBottom();
-
- final int childCount = parent.getChildCount();
- for (int i = 0; i < childCount; i++) {
- final View child = parent.getChildAt(i);
- final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
- .getLayoutParams();
- final int left = child.getRight() + params.rightMargin;
- final int right = left + mDivider.getIntrinsicHeight();
- mDivider.setBounds(left, top, right, bottom);
- mDivider.draw(c);
- }
- }
-
- @Override
- public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
- if (mOrientation == VERTICAL_LIST) {
- outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
- } else {
- outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java b/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java
deleted file mode 100644
index ac8cf13..0000000
--- a/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.afollestad.nocknock.views;
-
-import android.content.Context;
-import android.util.AttributeSet;
-import android.widget.ImageView;
-
-import com.afollestad.nocknock.R;
-import com.afollestad.nocknock.api.ServerStatus;
-
-/**
- * @author Aidan Follestad (afollestad)
- */
-public class StatusImageView extends ImageView {
-
- public StatusImageView(Context context) {
- super(context);
- setStatus(ServerStatus.OK);
- }
-
- public StatusImageView(Context context, AttributeSet attrs) {
- super(context, attrs);
- setStatus(ServerStatus.OK);
- }
-
- public StatusImageView(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- setStatus(ServerStatus.OK);
- }
-
- public void setStatus(@ServerStatus.Enum int status) {
- switch (status) {
- case ServerStatus.CHECKING:
- case ServerStatus.WAITING:
- setImageResource(R.drawable.status_progress);
- setBackgroundResource(R.drawable.yellow_circle);
- break;
- case ServerStatus.ERROR:
- setImageResource(R.drawable.status_error);
- setBackgroundResource(R.drawable.red_circle);
- break;
- case ServerStatus.OK:
- setImageResource(R.drawable.status_ok);
- setBackgroundResource(R.drawable.green_circle);
- break;
- }
- }
-}
diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml
deleted file mode 100644
index 900d3cc..0000000
--- a/app/src/main/res/anim/fade_out.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/color/unchecked_chip_text.xml b/app/src/main/res/color/unchecked_chip_text.xml
new file mode 100644
index 0000000..8e7f4df
--- /dev/null
+++ b/app/src/main/res/color/unchecked_chip_text.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable-hdpi-v11/ic_notification.png b/app/src/main/res/drawable-hdpi-v11/ic_notification.png
deleted file mode 100644
index 5c51b0b..0000000
Binary files a/app/src/main/res/drawable-hdpi-v11/ic_notification.png and /dev/null differ
diff --git a/app/src/main/res/drawable-mdpi-v11/ic_notification.png b/app/src/main/res/drawable-mdpi-v11/ic_notification.png
deleted file mode 100644
index 610685d..0000000
Binary files a/app/src/main/res/drawable-mdpi-v11/ic_notification.png and /dev/null differ
diff --git a/app/src/main/res/drawable-xhdpi-v11/ic_notification.png b/app/src/main/res/drawable-xhdpi-v11/ic_notification.png
deleted file mode 100644
index 88e634f..0000000
Binary files a/app/src/main/res/drawable-xhdpi-v11/ic_notification.png and /dev/null differ
diff --git a/app/src/main/res/drawable-xxhdpi-v11/ic_notification.png b/app/src/main/res/drawable-xxhdpi-v11/ic_notification.png
deleted file mode 100644
index dc4957d..0000000
Binary files a/app/src/main/res/drawable-xxhdpi-v11/ic_notification.png and /dev/null differ
diff --git a/app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png b/app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png
deleted file mode 100644
index af4779b..0000000
Binary files a/app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png and /dev/null differ
diff --git a/app/src/main/res/drawable/checked_chip.xml b/app/src/main/res/drawable/checked_chip.xml
new file mode 100644
index 0000000..85010f5
--- /dev/null
+++ b/app/src/main/res/drawable/checked_chip.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/checked_chip_pressed.xml b/app/src/main/res/drawable/checked_chip_pressed.xml
new file mode 100644
index 0000000..0d7c176
--- /dev/null
+++ b/app/src/main/res/drawable/checked_chip_pressed.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/checked_chip_selector.xml b/app/src/main/res/drawable/checked_chip_selector.xml
new file mode 100644
index 0000000..fa9df00
--- /dev/null
+++ b/app/src/main/res/drawable/checked_chip_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml
index 98beb0c..7561a3c 100644
--- a/app/src/main/res/drawable/divider.xml
+++ b/app/src/main/res/drawable/divider.xml
@@ -1,4 +1,4 @@
-
-
-
\ No newline at end of file
+
+
+
diff --git a/app/src/main/res/drawable/green_circle.xml b/app/src/main/res/drawable/green_circle.xml
deleted file mode 100644
index 31bc563..0000000
--- a/app/src/main/res/drawable/green_circle.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_action_close.xml b/app/src/main/res/drawable/ic_action_close.xml
index f4977c9..241f9be 100644
--- a/app/src/main/res/drawable/ic_action_close.xml
+++ b/app/src/main/res/drawable/ic_action_close.xml
@@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
-
+
diff --git a/app/src/main/res/drawable/ic_action_delete.xml b/app/src/main/res/drawable/ic_action_delete.xml
index d85947c..901c3e1 100644
--- a/app/src/main/res/drawable/ic_action_delete.xml
+++ b/app/src/main/res/drawable/ic_action_delete.xml
@@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/res/drawable/ic_action_refresh.xml b/app/src/main/res/drawable/ic_action_refresh.xml
index 85eb103..5175bda 100644
--- a/app/src/main/res/drawable/ic_action_refresh.xml
+++ b/app/src/main/res/drawable/ic_action_refresh.xml
@@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml
index 7b18128..c0ca276 100644
--- a/app/src/main/res/drawable/ic_add.xml
+++ b/app/src/main/res/drawable/ic_add.xml
@@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
-
+
diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml
new file mode 100644
index 0000000..00fc15d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_check.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_down_arrow.xml b/app/src/main/res/drawable/ic_down_arrow.xml
deleted file mode 100644
index 2341755..0000000
--- a/app/src/main/res/drawable/ic_down_arrow.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/red_circle.xml b/app/src/main/res/drawable/red_circle.xml
deleted file mode 100644
index d6bdd26..0000000
--- a/app/src/main/res/drawable/red_circle.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/status_error.xml b/app/src/main/res/drawable/status_error.xml
deleted file mode 100644
index 6f67037..0000000
--- a/app/src/main/res/drawable/status_error.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/status_ok.xml b/app/src/main/res/drawable/status_ok.xml
deleted file mode 100644
index 3ec2bc9..0000000
--- a/app/src/main/res/drawable/status_ok.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/status_progress.xml b/app/src/main/res/drawable/status_progress.xml
deleted file mode 100644
index c100e60..0000000
--- a/app/src/main/res/drawable/status_progress.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/unchecked_chip.xml b/app/src/main/res/drawable/unchecked_chip.xml
new file mode 100644
index 0000000..1864bc5
--- /dev/null
+++ b/app/src/main/res/drawable/unchecked_chip.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/unchecked_chip_pressed.xml b/app/src/main/res/drawable/unchecked_chip_pressed.xml
new file mode 100644
index 0000000..c387d70
--- /dev/null
+++ b/app/src/main/res/drawable/unchecked_chip_pressed.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/unchecked_chip_selector.xml b/app/src/main/res/drawable/unchecked_chip_selector.xml
new file mode 100644
index 0000000..ba01f74
--- /dev/null
+++ b/app/src/main/res/drawable/unchecked_chip_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/yellow_circle.xml b/app/src/main/res/drawable/yellow_circle.xml
deleted file mode 100644
index 1201e65..0000000
--- a/app/src/main/res/drawable/yellow_circle.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml
index 4bf5e29..80f83da 100644
--- a/app/src/main/res/layout/activity_addsite.xml
+++ b/app/src/main/res/layout/activity_addsite.xml
@@ -1,242 +1,222 @@
-
+ >
-
+
+
+
+ android:layout_height="match_parent"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:orientation="horizontal"
+ >
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
\ No newline at end of file
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 572f439..1e95b7f 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,105 +1,71 @@
-
+ tools:context=".ui.main.MainActivity"
+ >
-
+
+
+
+
-
-
-
-
-
-
-
-
+ 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"
+ />
-
+ android:scrollbars="vertical"
+ />
-
+
-
+
-
+
-
+
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/activity_viewsite.xml b/app/src/main/res/layout/activity_viewsite.xml
index 09fb8b4..581c555 100644
--- a/app/src/main/res/layout/activity_viewsite.xml
+++ b/app/src/main/res/layout/activity_viewsite.xml
@@ -1,295 +1,309 @@
-
+ >
-
+
+
+
+ android:layout_height="match_parent"
+ >
+
+
+
+
+ android:layout_marginTop="@dimen/content_inset_quarter"
+ android:orientation="horizontal"
+ >
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:hint="@string/site_url"
+ android:inputType="textUri"
+ android:nextFocusDown="@+id/inputTags"
+ android:singleLine="true"
+ android:transitionName="site_url"
+ tools:ignore="Autofill,UnusedAttribute"
+ style="@style/NockText.Body"
+ />
-
-
+ android:visibility="gone"
+ tools:text="Warning: this app checks for server availability with HTTP requests. It's recommended that you use an HTTP URL."
+ style="@style/NockText.Footnote"
+ />
-
-
-
+ 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"
+ />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_app_bar.xml b/app/src/main/res/layout/include_app_bar.xml
new file mode 100644
index 0000000..6e60df9
--- /dev/null
+++ b/app/src/main/res/layout/include_app_bar.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_divider.xml b/app/src/main/res/layout/include_divider.xml
new file mode 100644
index 0000000..a0dc832
--- /dev/null
+++ b/app/src/main/res/layout/include_divider.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/app/src/main/res/layout/include_empty_view.xml b/app/src/main/res/layout/include_empty_view.xml
new file mode 100644
index 0000000..1adf674
--- /dev/null
+++ b/app/src/main/res/layout/include_empty_view.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/app/src/main/res/layout/list_item_server.xml b/app/src/main/res/layout/list_item_server.xml
index 3e9ae2c..04e37c5 100644
--- a/app/src/main/res/layout/list_item_server.xml
+++ b/app/src/main/res/layout/list_item_server.xml
@@ -1,87 +1,86 @@
-
+ android:paddingTop="@dimen/content_inset"
+ >
-
+
-
+
+
+ android:orientation="horizontal"
+ >
-
+
-
+
-
+
-
+
-
+
-
+
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/list_item_spinner.xml b/app/src/main/res/layout/list_item_spinner.xml
deleted file mode 100644
index 32960dd..0000000
--- a/app/src/main/res/layout/list_item_spinner.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/list_item_tag.xml b/app/src/main/res/layout/list_item_tag.xml
new file mode 100644
index 0000000..0ae5932
--- /dev/null
+++ b/app/src/main/res/layout/list_item_tag.xml
@@ -0,0 +1,15 @@
+
+
diff --git a/app/src/main/res/layout/menu_item_refresh_icon.xml b/app/src/main/res/layout/menu_item_refresh_icon.xml
new file mode 100644
index 0000000..0079df9
--- /dev/null
+++ b/app/src/main/res/layout/menu_item_refresh_icon.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/menu/menu_addsite.xml b/app/src/main/res/menu/menu_addsite.xml
new file mode 100644
index 0000000..e346eb9
--- /dev/null
+++ b/app/src/main/res/menu/menu_addsite.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
index 5df480e..5f2f23f 100644
--- a/app/src/main/res/menu/menu_main.xml
+++ b/app/src/main/res/menu/menu_main.xml
@@ -1,8 +1,10 @@
\ No newline at end of file
+
+
+
diff --git a/app/src/main/res/menu/menu_viewsite.xml b/app/src/main/res/menu/menu_viewsite.xml
index 0d2128f..fee6f5a 100644
--- a/app/src/main/res/menu/menu_viewsite.xml
+++ b/app/src/main/res/menu/menu_viewsite.xml
@@ -1,17 +1,23 @@
\ No newline at end of file
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..80b730f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..80b730f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
index d1698c3..a86dbab 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..eb43a7b
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..4567198
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
index cccfa5b..60056be 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..666c904
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..3baff41
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index a4f8356..27f30d2 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..4224797
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1d29a54
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 49dd226..60a8d1a 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..7cf19eb
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1548eb9
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index bd57c86..ac61bd3 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..9c43fc9
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..04806fd
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values-v23/styles.xml b/app/src/main/res/values-v23/styles.xml
new file mode 100644
index 0000000..c1a8339
--- /dev/null
+++ b/app/src/main/res/values-v23/styles.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values-v27/styles.xml b/app/src/main/res/values-v27/styles.xml
new file mode 100644
index 0000000..c4efc7a
--- /dev/null
+++ b/app/src/main/res/values-v27/styles.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
new file mode 100644
index 0000000..84ad226
--- /dev/null
+++ b/app/src/main/res/values/arrays.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ - @string/refresh_status
+ - @string/duplicate_and_modify
+ - @string/remove_site
+
+
+
+ - Status Code
+ - Search Term
+ - JavaScript Evaluation
+
+
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..35d3041
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 8127492..f840cee 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,16 +1,17 @@
- #455A64
- #37474F
- #FF6E40
+ #FFFFFF
+ #F5F5F5
- #EEEEEE
+ #212121
+ #252525
- #E53935
- #FDD835
- #43A047
+ #303030
+ #EEEEEE
- #37474F
+ #FF6E40
+ #E44615
+ #40FF6E40
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 629f464..510a2e9 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -1,23 +1,7 @@
- 24sp
- 20sp
- 16sp
- 14sp
- 12sp
+ 28sp
+ 6dp
+ 4dp
- 8dp
- 12dp
- 16dp
- 24dp
- 32dp
-
- 42dp
- 4dp
- 4dp
- 8dp
- 52dp
- 300dp
- 14sp
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..70daa76
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #758F9A
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9e21c36..3ac40a0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,81 +1,90 @@
-
+
- Nock Nock (BETA)
+ Nock Nock
+ Nock Nock %1$s
- Everything checks out!
- Something\'s wrong! Tap for details.
- Checking status…
- Waiting…
+ No sites added!
- No sites added!
-
- About
- Nock Nock, a simple app designed by Aidan Follestad.
- Website
+ About
+ Aidan Follestad.
+ Website
Twitter
- Google+
GitHub
LinkedIn
Nock Nock is open source! Check out the GitHub page!
Icon by Kevin Aguilar of 221 Pixels.
- ]]>
- Dismiss
- Add Site
- Site Name
- Site URL
- Check Interval
- Done
- Please enter a name!
- Please enter a URL.
- Please enter a valid URL.
+
View the Privacy Policy.
+ ]]>
+ Dark Mode
- Request timed out! Your server is probably down.
- Options
- Already checking sites!
- Remove Site
- %1$s from your sites?]]>
- Remove
- Save
- View Site
- Last Check Result
- Next Check
- None (turned off)
- None
+ Dismiss
+ Add Site
+ Site Name
+ Site display name
+ Site URL
+ https://yoursite.com
+ Site Tags
+ e.g. One,Two,Three
+ Tags (e.g. One,Two,Three)
+ Please enter a name!
+ Please enter a URL.
+ Please enter a valid URL.
+ Please input a search term.
+ Please enter a network timeout greater than 0.
+ Certificate should be a valid file or content URI.
- Refresh Status
- Drag the list down to manually refresh site statuses! Otherwise, they will be updated automatically in the background on chosen intervals.
- Understood!
+ Options
+ Remove Site
+ Duplicate and Modify
+ %1$s from your sites?]]>
+ Remove
+ Save Changes
+ View Site
+ Last Validation Result
+ Next Validation
+ Next Validation: %1$s
+ Now
+ None
-
- Warning: this app checks for server availability with HTTP requests. It\'s recommended that you use an HTTP URL.
-
- var responseObj = JSON.parse(response);\nreturn responseObj.success === true;
- function validate(response) {
- }
- Response Validation Mode
- Search term…
+ Disable Automatic Validation
+ %1$s? The site will not be checked in the background
+ until you re-enable validation by tapping the checkmark (Save) icon. You can still manually
+ perform validation by tapping the Refresh icon at the top of this page.
+ ]]>
+ Disable
+ Enable Auto Validation & Save Changes
- The HTTP status code is checked. If it\'s a successful status code, the site passes the check.
- The status code check is done first. If it\'s successful, the response body is checked. If it contains your search term, the site passes the check.
- The status code check is done first. If it\'s successful, the response body is passed to the JavaScript function above. If the function returns true, the site passes the check. Throw an exception to pass custom error messages to Nock Nock.
+ Network Response Timeout (ms)
+ 10000
-
- - Minute(s)
- - Hour(s)
- - Day(s)
- - Weeks(s)
-
+ SSL Certificate
+ (Automatic)
+ Browse
-
- - @string/refresh_status
- - @string/remove_site
-
+ Refresh Status
-
- - Status Code
- - Search Term
- - JavaScript Evaluation
-
+
+ Warning: this app validates sites availability with HTTP requests. It\'s recommended that you
+ use an HTTP URL.
+
+ Response Validation Mode
+ Search term…
+
+
+ The HTTP status code is checked. If it\'s a successful status code, the site passes validation.
+
+
+ The status code check is done first. If it\'s successful, the response body is checked.
+ If it contains your search term, the site passes validation.
+
+
+ The status code check is done first. If it\'s successful, the response body is passed to the
+ JavaScript function above. If the function returns true, the site passes validation. Throw an
+ exception to pass custom error messages to Nock Nock.
+
+
+ Please install a web browser app, such as Google Chrome.
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 1a75349..e4b435a 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -1,35 +1,13 @@
-
-
-
-
-
-
-
+
diff --git a/app/src/main/res/values/styles_parents.xml b/app/src/main/res/values/styles_parents.xml
new file mode 100644
index 0000000..dc5e3d6
--- /dev/null
+++ b/app/src/main/res/values/styles_parents.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/styles_text.xml b/app/src/main/res/values/styles_text.xml
new file mode 100644
index 0000000..df25036
--- /dev/null
+++ b/app/src/main/res/values/styles_text.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/test/java/com/afollestad/nocknock/TestData.kt b/app/src/test/java/com/afollestad/nocknock/TestData.kt
new file mode 100644
index 0000000..e5e4c29
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/TestData.kt
@@ -0,0 +1,216 @@
+/**
+ * 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
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.IntentFilter
+import com.afollestad.nocknock.data.AppDatabase
+import com.afollestad.nocknock.data.HeaderDao
+import com.afollestad.nocknock.data.RetryPolicyDao
+import com.afollestad.nocknock.data.SiteDao
+import com.afollestad.nocknock.data.SiteSettingsDao
+import com.afollestad.nocknock.data.ValidationResultsDao
+import com.afollestad.nocknock.data.model.Header
+import com.afollestad.nocknock.data.model.RetryPolicy
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.SiteSettings
+import com.afollestad.nocknock.data.model.Status
+import com.afollestad.nocknock.data.model.Status.OK
+import com.afollestad.nocknock.data.model.ValidationMode
+import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
+import com.afollestad.nocknock.data.model.ValidationResult
+import com.afollestad.nocknock.utilities.providers.CanNotifyModel
+import com.afollestad.nocknock.utilities.providers.IntentProvider
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.doAnswer
+import com.nhaarman.mockitokotlin2.doReturn
+import com.nhaarman.mockitokotlin2.isA
+import com.nhaarman.mockitokotlin2.mock
+import java.lang.System.currentTimeMillis
+
+fun fakeIntent(action: String): Intent {
+ return mock {
+ on { getAction() } doReturn action
+ }
+}
+
+fun fakeSettingsModel(
+ id: Long,
+ validationMode: ValidationMode = STATUS_CODE
+) = SiteSettings(
+ siteId = id,
+ validationIntervalMs = 600000,
+ validationMode = validationMode,
+ validationArgs = null,
+ disabled = false,
+ networkTimeout = 10000,
+ certificate = null
+)
+
+fun fakeResultModel(
+ id: Long,
+ status: Status = OK,
+ reason: String? = null
+) = ValidationResult(
+ siteId = id,
+ status = status,
+ reason = reason,
+ timestampMs = currentTimeMillis()
+)
+
+fun fakeRetryPolicy(
+ id: Long,
+ count: Int = 3,
+ minutes: Int = 6
+) = RetryPolicy(
+ siteId = id,
+ count = count,
+ minutes = minutes
+)
+
+fun fakeHeaders(siteId: Long): List {
+ return listOf(
+ Header(id = siteId + 1, siteId = siteId, key = "Content-Type", value = "text/html"),
+ Header(id = siteId + 2, siteId = siteId, key = "User-Agent", value = "NockNock")
+ )
+}
+
+fun fakeModel(
+ id: Long,
+ tags: String = ""
+) = Site(
+ id = id,
+ name = "Test",
+ url = "https://test.com",
+ tags = tags,
+ settings = fakeSettingsModel(id),
+ lastResult = fakeResultModel(id),
+ retryPolicy = fakeRetryPolicy(id),
+ headers = fakeHeaders(id)
+)
+
+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 {
+ val siteDao = mock {
+ on { insert(isA()) } doReturn 1
+ on { one(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> listOf(MOCK_MODEL_1)
+ 2L -> listOf(MOCK_MODEL_2)
+ 3L -> listOf(MOCK_MODEL_3)
+ else -> listOf()
+ }
+ }
+ on { all() } doReturn ALL_MOCK_MODELS
+ on { update(isA()) } doAnswer { inv ->
+ return@doAnswer inv.arguments.size
+ }
+ on { delete(isA()) } doAnswer { inv ->
+ return@doAnswer inv.arguments.size
+ }
+ }
+ val settingsDao = mock {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> listOf(MOCK_MODEL_1.settings!!)
+ 2L -> listOf(MOCK_MODEL_2.settings!!)
+ 3L -> listOf(MOCK_MODEL_3.settings!!)
+ else -> listOf()
+ }
+ }
+ on { update(isA()) } doReturn 1
+ on { delete(isA()) } doReturn 1
+ }
+ val resultsDao = mock {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> listOf(MOCK_MODEL_1.lastResult!!)
+ 2L -> listOf(MOCK_MODEL_2.lastResult!!)
+ 3L -> listOf(MOCK_MODEL_3.lastResult!!)
+ else -> listOf()
+ }
+ }
+ on { update(isA()) } doReturn 1
+ on { delete(isA()) } doReturn 1
+ }
+ val retryDao = mock {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> listOf(MOCK_MODEL_1.retryPolicy!!)
+ 2L -> listOf(MOCK_MODEL_2.retryPolicy!!)
+ 3L -> listOf(MOCK_MODEL_3.retryPolicy!!)
+ else -> listOf()
+ }
+ }
+ on { update(isA()) } doReturn 1
+ on { delete(isA()) } doReturn 1
+ }
+ val headerDao = mock {
+ on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> MOCK_MODEL_1.headers
+ 2L -> MOCK_MODEL_2.headers
+ 3L -> MOCK_MODEL_3.headers
+ else -> listOf()
+ }
+ }
+ on { insert(isA()) } doReturn 1L
+ on { insert(isA>()) } doReturn listOf(1L, 2L)
+ on { update(isA()) } doReturn 1
+ on { delete(isA()) } doReturn 1
+ }
+
+ return mock {
+ on { siteDao() } doReturn siteDao
+ on { siteSettingsDao() } doReturn settingsDao
+ on { validationResultsDao() } doReturn resultsDao
+ on { retryPolicyDao() } doReturn retryDao
+ on { headerDao() } doReturn headerDao
+ }
+}
+
+fun mockIntentProvider() = object : IntentProvider {
+ override fun createFilter(vararg actions: String): IntentFilter {
+ return mock {
+ on { this.getAction(any()) } doAnswer { inv ->
+ val index = inv.getArgument(0)
+ return@doAnswer actions[index]
+ }
+ on { this.actionsIterator() } doReturn actions.iterator()
+ on { this.countActions() } doReturn actions.size
+ }
+ }
+
+ override fun getPendingIntentForViewSite(model: CanNotifyModel): PendingIntent {
+ // basically no-op right now
+ return mock()
+ }
+}
diff --git a/app/src/test/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiverTest.kt b/app/src/test/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiverTest.kt
new file mode 100644
index 0000000..e3e51b3
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiverTest.kt
@@ -0,0 +1,73 @@
+/**
+ * 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.broadcasts
+
+import android.app.Application
+import android.content.IntentFilter
+import com.afollestad.nocknock.MOCK_MODEL_2
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL
+import com.afollestad.nocknock.fakeIntent
+import com.afollestad.nocknock.mockIntentProvider
+import com.google.common.truth.Truth.assertThat
+import com.nhaarman.mockitokotlin2.argumentCaptor
+import com.nhaarman.mockitokotlin2.doReturn
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.times
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Test
+
+/** @author Aidan Follestad (@afollestad) */
+class StatusUpdateIntentReceiverTest {
+
+ private val app = mock()
+ private val intentProvider = mockIntentProvider()
+ private val callback = mock()
+
+ private val receiver = StatusUpdateIntentReceiver(app, intentProvider, callback)
+
+ @Test fun onReceive() {
+ val badIntent = fakeIntent("Hello World")
+ receiver.intentReceiver.onReceive(app, badIntent)
+
+ val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
+ whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
+ .doReturn(MOCK_MODEL_2)
+
+ receiver.intentReceiver.onReceive(app, goodIntent)
+ verify(callback, times(1)).invoke(MOCK_MODEL_2)
+ }
+
+ @Test fun onResume() {
+ receiver.onResume()
+
+ val filterCaptor = argumentCaptor()
+ verify(app).registerReceiver(eq(receiver.intentReceiver), filterCaptor.capture())
+
+ val actionIterator = filterCaptor.firstValue.actionsIterator()
+ assertThat(actionIterator.hasNext()).isTrue()
+ val filterAction = actionIterator.next()
+ assertThat(filterAction).isEqualTo(ACTION_STATUS_UPDATE)
+ assertThat(actionIterator.hasNext()).isFalse()
+ }
+
+ @Test fun onPause() {
+ receiver.onPause()
+ verify(app).unregisterReceiver(receiver.intentReceiver)
+ }
+}
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt
new file mode 100644
index 0000000..1319310
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt
@@ -0,0 +1,206 @@
+/**
+ * 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 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.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.argumentCaptor
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Rule
+import org.junit.Test
+
+/** @author Aidan Follestad (@afollestad) */
+@ExperimentalCoroutinesApi
+class AddSiteViewModelTest {
+
+ private val database = mockDatabase()
+ private val validationManager = mock()
+
+ @Rule @JvmField val rule = InstantTaskExecutorRule()
+
+ private val viewModel = AddSiteViewModel(
+ database,
+ validationManager,
+ Dispatchers.Unconfined,
+ Dispatchers.Unconfined
+ )
+
+ @After fun tearDown() = viewModel.destroy()
+
+ @Test fun setDefaults() {
+ viewModel.setDefaults()
+
+ assertThat(viewModel.name.value).isNull()
+ assertThat(viewModel.url.value).isNull()
+ assertThat(viewModel.timeout.value).isEqualTo(10000)
+ assertThat(viewModel.validationMode.value).isEqualTo(STATUS_CODE)
+ assertThat(viewModel.validationSearchTerm.value).isNull()
+ assertThat(viewModel.validationScript.value).isNull()
+ assertThat(viewModel.checkIntervalValue.value).isEqualTo(0)
+ assertThat(viewModel.checkIntervalUnit.value).isEqualTo(MINUTE)
+ }
+
+ @Test fun onUrlWarningVisibility() {
+ val urlWarningVisibility = viewModel.onUrlWarningVisibility()
+ .test()
+
+ viewModel.url.value = ""
+ urlWarningVisibility.assertValues(false)
+
+ viewModel.url.value = "helloworld"
+ urlWarningVisibility.assertValues(true)
+
+ viewModel.url.value = "http://helloworld.com"
+ urlWarningVisibility.assertValues(false)
+
+ viewModel.url.value = "ftp://helloworld.com"
+ urlWarningVisibility.assertValues(true)
+ }
+
+ @Test fun onValidationModeDescription() {
+ val description = viewModel.onValidationModeDescription()
+ .test()
+
+ viewModel.validationMode.value = STATUS_CODE
+ description.assertValues(R.string.validation_mode_status_desc)
+
+ viewModel.validationMode.value = TERM_SEARCH
+ description.assertValues(R.string.validation_mode_term_desc)
+
+ viewModel.validationMode.value = JAVASCRIPT
+ description.assertValues(R.string.validation_mode_javascript_desc)
+ }
+
+ @Test fun onValidationSearchTermVisibility() {
+ val visibility = viewModel.onValidationSearchTermVisibility()
+ .test()
+
+ viewModel.validationMode.value = STATUS_CODE
+ visibility.assertValues(false)
+
+ viewModel.validationMode.value = TERM_SEARCH
+ visibility.assertValues(true)
+
+ viewModel.validationMode.value = JAVASCRIPT
+ visibility.assertValues(false)
+ }
+
+ @Test fun onValidationScriptVisibility() {
+ val visibility = viewModel.onValidationScriptVisibility()
+ .test()
+
+ viewModel.validationMode.value = STATUS_CODE
+ visibility.assertValues(false)
+
+ viewModel.validationMode.value = TERM_SEARCH
+ visibility.assertValues(false)
+
+ viewModel.validationMode.value = JAVASCRIPT
+ visibility.assertValues(true)
+ }
+
+ @Test fun getCheckIntervalMs() {
+ viewModel.checkIntervalValue.value = 3
+ viewModel.checkIntervalUnit.value = 200
+ assertThat(viewModel.getCheckIntervalMs()).isEqualTo(600L)
+ }
+
+ @Test fun getValidationArgs() {
+ viewModel.validationSearchTerm.value = "One"
+ viewModel.validationScript.value = "Two"
+
+ viewModel.validationMode.value = STATUS_CODE
+ assertThat(viewModel.getValidationArgs()).isNull()
+
+ viewModel.validationMode.value = TERM_SEARCH
+ assertThat(viewModel.getValidationArgs()).isEqualTo("One")
+
+ viewModel.validationMode.value = JAVASCRIPT
+ assertThat(viewModel.getValidationArgs()).isEqualTo("Two")
+ }
+
+ @Test fun commit_success() = runBlocking {
+ val isLoading = viewModel.onIsLoading()
+ .test()
+
+ fillInModel()
+ val onDone = mock<() -> Unit>()
+ viewModel.commit(onDone)
+
+ val siteCaptor = argumentCaptor()
+ val settingsCaptor = argumentCaptor()
+ val validationResultCaptor = argumentCaptor()
+
+ isLoading.assertValues(true, false)
+ verify(database.siteDao()).insert(siteCaptor.capture())
+ verify(database.siteSettingsDao()).insert(settingsCaptor.capture())
+ verify(database.validationResultsDao()).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 = result
+ )
+
+ assertThat(result.reason).isNull()
+ assertThat(result.status).isEqualTo(WAITING)
+
+ verify(validationManager).scheduleValidation(
+ site = model,
+ rightNow = true,
+ cancelPrevious = true,
+ fromFinishingJob = false
+ )
+
+ verify(onDone).invoke()
+ }
+
+ private fun fillInModel() = viewModel.apply {
+ name.value = "Welcome to Wakanda"
+ url.value = "https://www.wakanda.gov"
+ timeout.value = 10000
+ validationMode.value = TERM_SEARCH
+ validationSearchTerm.value = "T'Challa"
+ validationScript.value = null
+ checkIntervalValue.value = 60
+ checkIntervalUnit.value = 1000
+ tags.value = "one,two"
+ headers.value = listOf(
+ Header(2L, 1L, key = "Content-Type", value = "text/html"),
+ Header(3L, 1L, key = "User-Agent", value = "NockNock")
+ )
+ }
+}
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt
new file mode 100644
index 0000000..593996e
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt
@@ -0,0 +1,189 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.main
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+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.ValidationExecutor
+import com.afollestad.nocknock.mockDatabase
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.utilities.livedata.test
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Rule
+import org.junit.Test
+
+/** @author Aidan Follestad (@afollestad) */
+@ExperimentalCoroutinesApi
+class MainViewModelTest {
+
+ private val database = mockDatabase()
+ private val notificationManager = mock()
+ private val validationManager = mock()
+
+ @Rule @JvmField val rule = InstantTaskExecutorRule()
+
+ private val viewModel = MainViewModel(
+ database,
+ notificationManager,
+ validationManager,
+ Dispatchers.Unconfined,
+ Dispatchers.Unconfined
+ )
+
+ @After fun tearDown() = viewModel.destroy()
+
+ @Test fun onResume() = 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.onResume()
+
+ verify(notificationManager).cancelStatusNotifications()
+ verify(validationManager).ensureScheduledValidations()
+
+ 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() {
+ val sites = viewModel.onSites()
+ .test()
+ viewModel.postSiteUpdate(MOCK_MODEL_1)
+ sites.assertNoValues()
+ }
+
+ @Test fun postSiteUpdate() {
+ val sites = viewModel.onSites()
+ .test()
+
+ viewModel.onResume()
+ sites.assertValues(ALL_MOCK_MODELS)
+
+ val updatedModel2 = MOCK_MODEL_2.copy(
+ name = "Wakanda Forever!!!"
+ )
+ val updatedSites = ALL_MOCK_MODELS.toMutableList()
+ .apply {
+ this[1] = updatedModel2
+ }
+ viewModel.postSiteUpdate(updatedModel2)
+
+ sites.assertValues(updatedSites)
+ }
+
+ @Test fun refreshSite() {
+ viewModel.refreshSite(MOCK_MODEL_3)
+
+ verify(validationManager).scheduleValidation(
+ site = MOCK_MODEL_3,
+ rightNow = true,
+ cancelPrevious = true
+ )
+ }
+
+ @Test fun removeSite_notFound() {
+ val sites = viewModel.onSites()
+ .test()
+ val isLoading = viewModel.onIsLoading()
+ .test()
+
+ viewModel.onResume()
+ sites.assertValues(ALL_MOCK_MODELS)
+ isLoading.assertValues(true, false)
+
+ val modifiedModel = MOCK_MODEL_1.copy(id = 11111)
+ viewModel.removeSite(modifiedModel)
+
+ sites.assertNoValues()
+ isLoading.assertValues(true, false)
+
+ verify(validationManager).cancelScheduledValidation(modifiedModel)
+ verify(notificationManager).cancelStatusNotification(modifiedModel)
+ verify(database.siteDao()).delete(modifiedModel)
+ verify(database.siteSettingsDao()).delete(modifiedModel.settings!!)
+ }
+
+ @Test fun removeSite() {
+ val sites = viewModel.onSites()
+ .test()
+ val emptyTextVisibility = viewModel.onEmptyTextVisibility()
+ .test()
+ val isLoading = viewModel.onIsLoading()
+ .test()
+
+ viewModel.onResume()
+ sites.assertValues(ALL_MOCK_MODELS)
+ isLoading.assertValues(true, false)
+
+ val modelsWithout1 = ALL_MOCK_MODELS.toMutableList()
+ .apply {
+ removeAt(0)
+ }
+ viewModel.removeSite(MOCK_MODEL_1)
+
+ sites.assertValues(modelsWithout1)
+ isLoading.assertValues(true, false)
+ emptyTextVisibility.assertValues(false, false, false)
+
+ verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
+ verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
+ verify(database.siteDao()).delete(MOCK_MODEL_1)
+ verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
+ }
+}
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt
new file mode 100644
index 0000000..47fe6e0
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt
@@ -0,0 +1,395 @@
+/**
+ * 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.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
+import com.afollestad.nocknock.data.model.Status.ERROR
+import com.afollestad.nocknock.data.model.Status.OK
+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.data.model.ValidationResult
+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
+import com.afollestad.nocknock.utilities.providers.StringProvider
+import com.google.common.truth.Truth
+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.verify
+import com.nhaarman.mockitokotlin2.whenever
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Rule
+import org.junit.Test
+import java.util.Calendar
+
+/** @author Aidan Follestad (@afollestad) */
+@ExperimentalCoroutinesApi
+class ViewSiteViewModelTest {
+
+ companion object {
+ private const val TEXT_NONE = "None"
+ private const val TEXT_EVERYTHING_CHECKS_OUT = "Everything checks out!"
+ private const val TEXT_WAITING = "Waiting..."
+ private const val TEXT_CHECKING = "Checking..."
+ private const val TEXT_CHECKS_DISABLED = "Automatic Checks Disabled"
+ }
+
+ private val stringProvider = mock {
+ on { get(any()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ when (id) {
+ R.string.none -> TEXT_NONE
+ R.string.everything_checks_out -> TEXT_EVERYTHING_CHECKS_OUT
+ R.string.waiting -> TEXT_WAITING
+ R.string.checking_status -> TEXT_CHECKING
+ R.string.auto_checks_disabled -> TEXT_CHECKS_DISABLED
+ else -> ""
+ }
+ }
+ }
+ private val database = mockDatabase()
+ private val validationManager = mock()
+ private val notificationManager = mock()
+
+ @Rule @JvmField val rule = InstantTaskExecutorRule()
+
+ private val viewModel = ViewSiteViewModel(
+ stringProvider,
+ database,
+ notificationManager,
+ validationManager,
+ Dispatchers.Unconfined,
+ Dispatchers.Unconfined
+ )
+
+ @After fun tearDown() = viewModel.destroy()
+
+ @Test fun onUrlWarningVisibility() {
+ val urlWarningVisibility = viewModel.onUrlWarningVisibility()
+ .test()
+
+ viewModel.url.value = ""
+ urlWarningVisibility.assertValues(false)
+
+ viewModel.url.value = "helloworld"
+ urlWarningVisibility.assertValues(true)
+
+ viewModel.url.value = "http://helloworld.com"
+ urlWarningVisibility.assertValues(false)
+
+ viewModel.url.value = "ftp://helloworld.com"
+ urlWarningVisibility.assertValues(true)
+ }
+
+ @Test fun onValidationModeDescription() {
+ val description = viewModel.onValidationModeDescription()
+ .test()
+
+ viewModel.validationMode.value = STATUS_CODE
+ description.assertValues(R.string.validation_mode_status_desc)
+
+ viewModel.validationMode.value = TERM_SEARCH
+ description.assertValues(R.string.validation_mode_term_desc)
+
+ viewModel.validationMode.value = JAVASCRIPT
+ description.assertValues(R.string.validation_mode_javascript_desc)
+ }
+
+ @Test fun onValidationSearchTermVisibility() {
+ val visibility = viewModel.onValidationSearchTermVisibility()
+ .test()
+
+ viewModel.validationMode.value = STATUS_CODE
+ visibility.assertValues(false)
+
+ viewModel.validationMode.value = TERM_SEARCH
+ visibility.assertValues(true)
+
+ viewModel.validationMode.value = JAVASCRIPT
+ visibility.assertValues(false)
+ }
+
+ @Test fun onValidationScriptVisibility() {
+ val visibility = viewModel.onValidationScriptVisibility()
+ .test()
+
+ viewModel.validationMode.value = STATUS_CODE
+ visibility.assertValues(false)
+
+ viewModel.validationMode.value = TERM_SEARCH
+ visibility.assertValues(false)
+
+ viewModel.validationMode.value = JAVASCRIPT
+ visibility.assertValues(true)
+ }
+
+ @Test fun onDisableChecksVisibility() {
+ val visibility = viewModel.onDisableChecksVisibility()
+ .test()
+
+ viewModel.disabled.value = false
+ visibility.assertValues(true)
+
+ viewModel.disabled.value = true
+ visibility.assertValues(false)
+ }
+
+ @Test fun onDoneButtonText() {
+ val text = viewModel.onDoneButtonText()
+ .test()
+
+ viewModel.disabled.value = false
+ text.assertValues(R.string.save_changes)
+
+ viewModel.disabled.value = true
+ text.assertValues(R.string.renable_and_save_changes)
+ }
+
+ @Test fun onLastCheckResultText() {
+ val text = viewModel.onLastCheckResultText()
+ .test()
+ val lastResult = ValidationResult(
+ siteId = 1,
+ timestampMs = 10,
+ status = OK,
+ reason = "Hello, world!"
+ )
+
+ viewModel.lastResult.value = null
+ text.assertValues(TEXT_NONE)
+
+ viewModel.lastResult.value = lastResult
+ text.assertValues(TEXT_EVERYTHING_CHECKS_OUT)
+
+ viewModel.lastResult.value = lastResult.copy(status = WAITING)
+ text.assertValues(TEXT_WAITING)
+
+ viewModel.lastResult.value = lastResult.copy(status = CHECKING)
+ text.assertValues(TEXT_CHECKING)
+
+ viewModel.lastResult.value = lastResult.copy(
+ status = ERROR,
+ reason = "Uh oh!"
+ )
+ text.assertValues("Uh oh!")
+ }
+
+ @Test fun onNextCheckText() {
+ viewModel.checkIntervalValue.value = 60
+ viewModel.checkIntervalUnit.value = 5000
+
+ val text = viewModel.onNextCheckText()
+ .test()
+ val calendar = Calendar.getInstance()
+ .apply {
+ set(Calendar.YEAR, 2018)
+ set(Calendar.MONTH, Calendar.DECEMBER)
+ set(Calendar.DAY_OF_MONTH, 6)
+ set(Calendar.HOUR_OF_DAY, 8)
+ set(Calendar.MINUTE, 30)
+ set(Calendar.SECOND, 0)
+ }
+ val lastResult = ValidationResult(
+ siteId = 1,
+ timestampMs = calendar.timeInMillis,
+ status = OK,
+ reason = null
+ )
+
+ viewModel.disabled.value = true
+ viewModel.lastResult.value = lastResult
+ text.assertValues(TEXT_CHECKS_DISABLED)
+
+ viewModel.disabled.value = false
+ text.assertValues("December 6, 8:35 AM")
+ }
+
+ @Test fun getCheckIntervalMs() {
+ viewModel.checkIntervalValue.value = 3
+ viewModel.checkIntervalUnit.value = 200
+ Truth.assertThat(viewModel.getCheckIntervalMs())
+ .isEqualTo(600L)
+ }
+
+ @Test fun getValidationArgs() {
+ viewModel.validationSearchTerm.value = "One"
+ viewModel.validationScript.value = "Two"
+
+ viewModel.validationMode.value = STATUS_CODE
+ Truth.assertThat(viewModel.getValidationArgs())
+ .isNull()
+
+ viewModel.validationMode.value = TERM_SEARCH
+ Truth.assertThat(viewModel.getValidationArgs())
+ .isEqualTo("One")
+
+ viewModel.validationMode.value = JAVASCRIPT
+ Truth.assertThat(viewModel.getValidationArgs())
+ .isEqualTo("Two")
+ }
+
+ @Test fun commit_success() = runBlocking {
+ whenever(database.retryPolicyDao().forSite(any())).doReturn(listOf(fakeRetryPolicy(1)))
+
+ val isLoading = viewModel.onIsLoading()
+ .test()
+
+ fillInModel()
+ val onDone = mock<() -> Unit>()
+
+ viewModel.site = MOCK_MODEL_1
+ viewModel.commit(onDone)
+
+ val siteCaptor = argumentCaptor()
+ val settingsCaptor = argumentCaptor()
+ val resultCaptor = argumentCaptor()
+ val retryPolicyCaptor = argumentCaptor()
+
+ isLoading.assertValues(true, false)
+ verify(database.siteDao()).update(siteCaptor.capture())
+ verify(database.siteSettingsDao()).update(settingsCaptor.capture())
+ verify(database.validationResultsDao()).update(resultCaptor.capture())
+ verify(database.retryPolicyDao()).update(retryPolicyCaptor.capture())
+
+ // From fillInModel() below
+ val updatedSettings = MOCK_MODEL_1.settings!!.copy(
+ networkTimeout = 30000,
+ validationMode = JAVASCRIPT,
+ validationArgs = "throw 'Oh no!'",
+ disabled = false,
+ validationIntervalMs = 24 * 60000
+ )
+ 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,
+ retryPolicy = retryPolicy
+ )
+
+ assertThat(siteCaptor.firstValue).isEqualTo(updatedModel)
+ assertThat(settingsCaptor.firstValue).isEqualTo(updatedSettings)
+ assertThat(resultCaptor.firstValue).isEqualTo(updatedResult)
+
+ verify(validationManager).scheduleValidation(
+ site = updatedModel,
+ rightNow = true,
+ cancelPrevious = true,
+ fromFinishingJob = false
+ )
+
+ verify(onDone).invoke()
+ }
+
+ @Test fun checkNow() {
+ val status = viewModel.status.test()
+
+ viewModel.site = MOCK_MODEL_1
+ val expectedModel = MOCK_MODEL_1.copy(
+ lastResult = MOCK_MODEL_1.lastResult!!.copy(
+ status = WAITING
+ )
+ )
+
+ viewModel.checkNow()
+ verify(validationManager).scheduleValidation(
+ site = expectedModel,
+ rightNow = true,
+ cancelPrevious = true
+ )
+ status.assertValues(WAITING)
+ }
+
+ @Test fun removeSite() {
+ val isLoading = viewModel.onIsLoading()
+ .test()
+ val onDone = mock<() -> Unit>()
+
+ viewModel.site = MOCK_MODEL_1
+ viewModel.removeSite(onDone)
+ isLoading.assertValues(true, false)
+
+ 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!!)
+ verify(database.validationResultsDao()).delete(MOCK_MODEL_1.lastResult!!)
+ verify(onDone).invoke()
+ }
+
+ @Test fun disableSite() {
+ val isLoading = viewModel.onIsLoading()
+ .test()
+ val disabled = viewModel.disabled.test()
+
+ viewModel.site = MOCK_MODEL_1
+ viewModel.disableSite()
+ isLoading.assertValues(true, false)
+ disabled.assertValues(true)
+
+ val expectedSite = MOCK_MODEL_1.copy(
+ settings = MOCK_MODEL_1.settings!!.copy(
+ disabled = true
+ )
+ )
+
+ verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
+ verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
+ verify(database.siteDao()).update(expectedSite)
+ verify(database.siteSettingsDao()).update(expectedSite.settings!!)
+ verify(database.validationResultsDao()).update(expectedSite.lastResult!!)
+ }
+
+ private fun fillInModel() = viewModel.apply {
+ name.value = "Hello There"
+ url.value = "https://www.hellothere.com"
+ timeout.value = 30000
+ validationMode.value = JAVASCRIPT
+ validationSearchTerm.value = null
+ validationScript.value = "throw 'Oh no!'"
+ checkIntervalValue.value = 24
+ checkIntervalUnit.value = 60000
+ tags.value = "one,two"
+ retryPolicyTimes.value = 5
+ retryPolicyMinutes.value = 5
+ headers.value = listOf(
+ Header(2L, 1L, key = "Content-Type", value = "text/html"),
+ Header(3L, 1L, key = "User-Agent", value = "NockNock")
+ )
+ }
+}
diff --git a/art/showcase5.png b/art/showcase5.png
new file mode 100644
index 0000000..6198f1f
Binary files /dev/null and b/art/showcase5.png differ
diff --git a/art/showcasemain.png b/art/showcasemain.png
deleted file mode 100644
index 3d6a0c6..0000000
Binary files a/art/showcasemain.png and /dev/null differ
diff --git a/build.gradle b/build.gradle
index 75c86f6..2d8e462 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,24 +1,32 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
+apply from: './dependencies.gradle'
+apply from: './versionsPlugin.gradle'
buildscript {
- repositories {
- jcenter()
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:2.1.3'
+ apply from: './dependencies.gradle'
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
+ repositories {
+ google()
+ jcenter()
+ maven { url 'https://maven.fabric.io/public' }
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:' + versions.gradlePlugin
+ classpath 'com.diffplug.spotless:spotless-plugin-gradle:' + versions.spotlessPlugin
+ 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
+ }
}
allprojects {
- repositories {
- jcenter()
- maven { url "https://jitpack.io" }
- }
-}
+ repositories {
+ google()
+ jcenter()
+ maven { url "https://jitpack.io" }
+ }
-task clean(type: Delete) {
- delete rootProject.buildDir
+ tasks.withType(Javadoc).all {
+ enabled = false
+ }
}
diff --git a/common/.gitignore b/common/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/common/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/common/build.gradle b/common/build.gradle
new file mode 100644
index 0000000..5004349
--- /dev/null
+++ b/common/build.gradle
@@ -0,0 +1,44 @@
+apply from: '../dependencies.gradle'
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ compileSdkVersion versions.compileSdk
+
+ defaultConfig {
+ minSdkVersion versions.minSdk
+ targetSdkVersion versions.compileSdk
+ versionCode versions.publishVersionCode
+ versionName versions.publishVersion
+ }
+
+ packagingOptions {
+ exclude 'META-INF/atomicfu.kotlin_module'
+ }
+
+ // For Mozilla Rhino
+ lintOptions {
+ abortOnError false
+ }
+}
+
+dependencies {
+ implementation 'androidx.annotation:annotation:' + versions.androidxAnnotations
+ api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle
+
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
+ api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:' + versions.coroutines
+ api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:' + versions.coroutines
+
+ implementation 'org.koin:koin-android:' + versions.koin
+ 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
+ testImplementation 'androidx.arch.core:core-testing:' + versions.archTesting
+}
+
+apply from: '../spotless.gradle'
\ No newline at end of file
diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d67cd60
--- /dev/null
+++ b/common/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/CommonModule.kt b/common/src/main/java/com/afollestad/nocknock/utilities/CommonModule.kt
new file mode 100644
index 0000000..49ceb52
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/CommonModule.kt
@@ -0,0 +1,39 @@
+/**
+ * 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
+
+import com.afollestad.nocknock.utilities.Qualifiers.IO_DISPATCHER
+import com.afollestad.nocknock.utilities.Qualifiers.MAIN_DISPATCHER
+import com.afollestad.nocknock.utilities.providers.RealStringProvider
+import com.afollestad.nocknock.utilities.providers.StringProvider
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import org.koin.dsl.module.module
+
+object Qualifiers {
+ const val MAIN_DISPATCHER = "main_dispatcher"
+ const val IO_DISPATCHER = "io_dispatcher"
+}
+
+/** @author Aidan Follestad (@afollestad) */
+val commonModule = module {
+
+ factory(name = MAIN_DISPATCHER) { Dispatchers.Main }
+
+ factory(name = IO_DISPATCHER) { Dispatchers.IO }
+
+ factory { RealStringProvider(get()) } bind StringProvider::class
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/AnimationExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/AnimationExt.kt
new file mode 100644
index 0000000..171a5cd
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/AnimationExt.kt
@@ -0,0 +1,60 @@
+/**
+ * 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.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.view.View
+
+fun Animator.onEnd(cb: () -> Unit) {
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ super.onAnimationEnd(animation)
+ cb()
+ }
+ })
+}
+
+fun View.animateRotation(
+ loop: Boolean = true,
+ firstPass: Boolean = true,
+ durationPerRotation: Long = 1000,
+ degreesPerRotation: Float = 360f
+) {
+ if (firstPass) {
+ animate().cancel()
+ }
+ animate()
+ .rotationBy(degreesPerRotation)
+ .setDuration(durationPerRotation)
+ .setListener(object : AnimatorListenerAdapter() {
+
+ var isCancelled = false
+
+ override fun onAnimationCancel(animation: Animator?) {
+ super.onAnimationCancel(animation)
+ isCancelled = true
+ }
+
+ override fun onAnimationEnd(animation: Animator) {
+ super.onAnimationEnd(animation)
+ if (loop && !isCancelled) {
+ animateRotation(loop = true, firstPass = false)
+ }
+ }
+ })
+ .start()
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/ContextExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/ContextExt.kt
new file mode 100644
index 0000000..4080195
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/ContextExt.kt
@@ -0,0 +1,23 @@
+/**
+ * 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.content.Context
+
+/** @author Aidan Follestad (@afollestad) */
+inline fun Context.systemService(name: String): T {
+ return getSystemService(name) as T
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt
new file mode 100644
index 0000000..6ef6e7f
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt
@@ -0,0 +1,28 @@
+/**
+ * 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 java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+fun Long.formatDate(): String {
+ if (this <= 0) {
+ return "(None)"
+ }
+ val df = SimpleDateFormat("MMMM d, h:mm a", Locale.getDefault())
+ return df.format(Date(this))
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt
new file mode 100644
index 0000000..1256a27
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/StringExt.kt
@@ -0,0 +1,27 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import android.net.Uri
+
+fun String.toUri() = Uri.parse(this)!!
+
+fun String?.isNotNullOrEmpty(): Boolean {
+ if (this == null || this == "null") {
+ return false
+ }
+ return !isNullOrEmpty()
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/TimeExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/TimeExt.kt
new file mode 100644
index 0000000..a7c65e2
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/TimeExt.kt
@@ -0,0 +1,46 @@
+/**
+ * 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 kotlin.math.ceil
+
+const val SECOND: Long = 1000
+const val MINUTE = SECOND * 60
+const val HOUR = MINUTE * 60
+const val DAY = HOUR * 24
+const val WEEK = DAY * 7
+const val MONTH = WEEK * 4
+
+fun Long.timeString() = when {
+ this <= 0 -> "??"
+ this >= MONTH ->
+ "${ceil((this.toFloat() / MONTH.toFloat()).toDouble()).toInt()}mo"
+ this >= WEEK ->
+ "${ceil((this.toFloat() / WEEK.toFloat()).toDouble()).toInt()}w"
+ this >= DAY ->
+ "${ceil((this.toFloat() / DAY.toFloat()).toDouble()).toInt()}d"
+ this >= HOUR ->
+ "${ceil((this.toFloat() / HOUR.toFloat()).toDouble()).toInt()}h"
+ this >= MINUTE -> {
+ val result = "${ceil((this.toFloat() / MINUTE.toFloat()).toDouble()).toInt()}m"
+ if (result == "60m") {
+ "1h"
+ } else {
+ result
+ }
+ }
+ else -> "<1m"
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
new file mode 100644
index 0000000..389de77
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
@@ -0,0 +1,72 @@
+/**
+ * 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.text.Editable
+import android.text.TextWatcher
+import android.widget.EditText
+import androidx.annotation.IntRange
+import kotlin.math.min
+
+fun EditText.setTextAndMaintainSelection(text: CharSequence?) {
+ if (text == null) {
+ setText("")
+ return
+ }
+
+ val formerStart = min(selectionStart, text.length)
+ val formerEnd = min(selectionEnd, text.length)
+ setText(text)
+ if (formerEnd <= formerStart) {
+ setSelection(formerStart)
+ } else {
+ setSelection(formerStart, formerEnd)
+ }
+}
+
+fun EditText.onTextChanged(
+ @IntRange(from = 0, to = 10000) debounce: Int = 0,
+ cb: (String) -> Unit
+) {
+ addTextChangedListener(object : TextWatcher {
+ val callbackRunner = Runnable {
+ cb(text.trim().toString())
+ }
+
+ override fun afterTextChanged(s: Editable?) = Unit
+
+ override fun beforeTextChanged(
+ s: CharSequence,
+ start: Int,
+ count: Int,
+ after: Int
+ ) = Unit
+
+ override fun onTextChanged(
+ s: CharSequence,
+ start: Int,
+ before: Int,
+ count: Int
+ ) {
+ removeCallbacks(callbackRunner)
+ if (debounce == 0) {
+ callbackRunner.run()
+ } else {
+ postDelayed(callbackRunner, debounce.toLong())
+ }
+ }
+ })
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/js/JavaScript.kt b/common/src/main/java/com/afollestad/nocknock/utilities/js/JavaScript.kt
new file mode 100644
index 0000000..cb94693
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/js/JavaScript.kt
@@ -0,0 +1,81 @@
+/**
+ * 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.js
+
+import org.mozilla.javascript.Context
+import org.mozilla.javascript.EvaluatorException
+import org.mozilla.javascript.Function
+
+/** @author Aidan Follestad (@afollestad) */
+object JavaScript {
+
+ fun eval(
+ code: String,
+ response: String
+ ): String? {
+ try {
+ val func = String.format(
+ "function validate(response) { " +
+ "try { " +
+ "%s " +
+ "} catch(e) { " +
+ "return e; " +
+ "} " +
+ "}",
+ code.replace("\n", " ")
+ )
+
+ // Every Rhino VM begins with the enter()
+ // This Context is not Android's Context
+ val rhino = Context.enter()
+
+ // Turn off optimization to make Rhino Android compatible
+ rhino.optimizationLevel = -1
+ try {
+ val scope = rhino.initStandardObjects()
+
+ // Note the forth argument is 1, which means the JavaScript source has
+ // been compressed to only one line using something like YUI
+ rhino.evaluateString(scope, func, "JavaScript", 1, null)
+
+ // Get the functionName defined in JavaScriptCode
+ val jsFunction = scope.get("validate", scope) as Function
+
+ // Call the function with params
+ val jsResult = jsFunction.call(rhino, scope, scope, arrayOf(response))
+
+ // Parse the jsResult object to a String
+ val result = Context.toString(jsResult)
+
+ val success = result != null && result == "true"
+ var message = "The script returned a value other than true!"
+ if (!success && result != null && result != "false") {
+ message = if (result == "undefined") {
+ "The script did not return or throw anything!"
+ } else {
+ result
+ }
+ }
+
+ return if (!success) message else null
+ } finally {
+ Context.exit()
+ }
+ } catch (e: EvaluatorException) {
+ return e.message
+ }
+ }
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/livedata/Distinct.kt b/common/src/main/java/com/afollestad/nocknock/utilities/livedata/Distinct.kt
new file mode 100644
index 0000000..d6a26d2
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/livedata/Distinct.kt
@@ -0,0 +1,53 @@
+/**
+ * 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.livedata
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+import androidx.lifecycle.Observer
+
+/** @author Aidan Follestad (@afollestad) */
+class DistinctLiveData(source1: LiveData) : MediatorLiveData() {
+
+ private var isInitialized = false
+ private var lastValue: T? = null
+
+ init {
+ super.addSource(source1) {
+ if (!isInitialized) {
+ value = it
+ isInitialized = true
+ lastValue = it
+ } else if (lastValue != it) {
+ value = it
+ lastValue = it
+ }
+ }
+ }
+
+ override fun addSource(
+ source: LiveData,
+ onChanged: Observer
+ ) {
+ throw UnsupportedOperationException()
+ }
+
+ override fun removeSource(toRemote: LiveData) {
+ throw UnsupportedOperationException()
+ }
+}
+
+fun LiveData.distinct(): MediatorLiveData = DistinctLiveData(this)
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/livedata/LiveDataExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/livedata/LiveDataExt.kt
new file mode 100644
index 0000000..c74ed4d
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/livedata/LiveDataExt.kt
@@ -0,0 +1,25 @@
+/**
+ * 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.livedata
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
+
+fun LiveData.map(mapper: (X) -> Y) =
+ Transformations.map(this, mapper)!!
+
+//fun LiveData.switchMap(mapper: (X) -> LiveData) =
+// Transformations.switchMap(this, mapper)!!
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/livedata/TestLiveData.kt b/common/src/main/java/com/afollestad/nocknock/utilities/livedata/TestLiveData.kt
new file mode 100644
index 0000000..7b94e95
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/livedata/TestLiveData.kt
@@ -0,0 +1,63 @@
+/**
+ * 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.livedata
+
+import androidx.annotation.CheckResult
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+
+/** @author Aidan Follestad (@afollestad) */
+class TestLiveData(data: LiveData) {
+
+ private val receivedValues = mutableListOf()
+ private val observer = Observer { emission ->
+ emission?.let { receivedValues.add(it) }
+ }
+
+ init {
+ data.observeForever(observer)
+ }
+
+ fun assertNoValues() {
+ if (receivedValues.isNotEmpty()) {
+ throw AssertionError("Expected no values, but got: $receivedValues")
+ }
+ }
+
+ fun assertValues(vararg assertValues: T) {
+ val assertList = assertValues.toList()
+ if (!assertList.contentEquals(receivedValues)) {
+ throw AssertionError("Expected $assertList\n\t\tBut got: $receivedValues")
+ }
+ receivedValues.clear()
+ }
+
+ @CheckResult fun values(): List = receivedValues
+
+ private fun List.contentEquals(other: List): Boolean {
+ if (this.size != other.size) {
+ return false
+ }
+ for ((index, value) in this.withIndex()) {
+ if (other[index] != value) {
+ return false
+ }
+ }
+ return true
+ }
+}
+
+@CheckResult fun LiveData.test() = TestLiveData(this)
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/livedata/Zip.kt b/common/src/main/java/com/afollestad/nocknock/utilities/livedata/Zip.kt
new file mode 100644
index 0000000..739fa5b
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/livedata/Zip.kt
@@ -0,0 +1,102 @@
+/**
+ * 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.livedata
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+import androidx.lifecycle.Observer
+
+typealias Zipper = (T, K) -> R
+
+/** @author Aidan Follestad (@afollestad) */
+class ZipLiveData(
+ source1: LiveData,
+ source2: LiveData,
+ private val distinctUntilChanged: Boolean,
+ private val resetAfterEmission: Boolean,
+ private val zipper: Zipper
+) : MediatorLiveData() {
+
+ private var data1: T? = null
+ private var data2: K? = null
+ private var lastNotified: R? = null
+
+ init {
+ super.addSource(source1) {
+ if (data1 == it) return@addSource
+ data1 = it
+ maybeNotify()
+ }
+ super.addSource(source2) {
+ if (data2 == it) return@addSource
+ data2 = it
+ maybeNotify()
+ }
+ }
+
+ private fun maybeNotify() {
+ if (data1 != null && data2 != null) {
+ val zippedUp = zipper(data1!!, data2!!)
+
+ if (!distinctUntilChanged || zippedUp != lastNotified) {
+ value = zippedUp
+ lastNotified = zippedUp
+
+ if (resetAfterEmission) {
+ data1 = null
+ data2 = null
+ }
+ }
+ }
+ }
+
+ override fun addSource(
+ source: LiveData,
+ onChanged: Observer
+ ) {
+ throw UnsupportedOperationException()
+ }
+
+ override fun removeSource(toRemote: LiveData) {
+ throw UnsupportedOperationException()
+ }
+}
+
+fun zip(
+ source1: LiveData,
+ source2: LiveData,
+ distinctUntilChanged: Boolean = true,
+ resetAfterEmission: Boolean = false,
+ zipper: Zipper
+) = ZipLiveData(
+ source1 = source1,
+ source2 = source2,
+ distinctUntilChanged = distinctUntilChanged,
+ resetAfterEmission = resetAfterEmission,
+ zipper = zipper
+)
+
+fun zip(
+ source1: LiveData,
+ source2: LiveData,
+ distinctUntilChanged: Boolean = true,
+ resetAfterEmission: Boolean = false
+) = zip(
+ source1 = source1,
+ source2 = source2,
+ distinctUntilChanged = distinctUntilChanged,
+ resetAfterEmission = resetAfterEmission,
+ zipper = { left, right -> Pair(left, right) })
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt
new file mode 100644
index 0000000..27894fe
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt
@@ -0,0 +1,48 @@
+/**
+ * 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.providers
+
+import android.os.PersistableBundle
+
+interface IBundle {
+ fun putLong(
+ key: String,
+ value: Long
+ )
+}
+
+typealias IBundler = IBundle.() -> Unit
+
+/** @author Aidan Follestad (@afollestad) */
+interface BundleProvider {
+
+ fun createPersistable(bundler: IBundle.() -> Unit): PersistableBundle
+}
+
+/** @author Aidan Follestad (@afollestad) */
+class RealBundleProvider : BundleProvider {
+
+ override fun createPersistable(bundler: IBundler): PersistableBundle {
+ val realBundle = PersistableBundle()
+ bundler(object : IBundle {
+ override fun putLong(
+ key: String,
+ value: Long
+ ) = realBundle.putLong(key, value)
+ })
+ return realBundle
+ }
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt
new file mode 100644
index 0000000..773243f
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt
@@ -0,0 +1,76 @@
+/**
+ * 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.providers
+
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_CANCEL_CURRENT
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import java.io.Serializable
+
+/** @author Aidan Follestad (@afollestad) */
+interface CanNotifyModel : Serializable {
+
+ fun notifyId(): Int
+
+ fun notifyName(): String
+
+ fun notifyTag(): String
+
+ fun notifyDescription(): String?
+}
+
+/** @author Aidan Follestad (@afollestad) */
+interface IntentProvider {
+
+ fun createFilter(vararg actions: String): IntentFilter
+
+ fun getPendingIntentForViewSite(
+ model: CanNotifyModel
+ ): PendingIntent
+}
+
+/** @author Aidan Follestad (@afollestad) */
+class RealIntentProvider(
+ private val context: Context,
+ private val mainActivityClass: Class<*>
+) : IntentProvider {
+
+ companion object {
+ const val BASE_NOTIFICATION_REQUEST_CODE = 40
+ const val KEY_VIEW_NOTIFICATION_MODEL = "model"
+ }
+
+ override fun createFilter(vararg actions: String) = IntentFilter().apply {
+ actions.forEach { addAction(it) }
+ }
+
+ override fun getPendingIntentForViewSite(model: CanNotifyModel): PendingIntent {
+ val openIntent = getIntentForViewSite(model)
+ return PendingIntent.getActivity(
+ context,
+ BASE_NOTIFICATION_REQUEST_CODE + model.notifyId(),
+ openIntent,
+ FLAG_CANCEL_CURRENT
+ )
+ }
+
+ private fun getIntentForViewSite(model: CanNotifyModel) =
+ Intent(context, mainActivityClass).apply {
+ putExtra(KEY_VIEW_NOTIFICATION_MODEL, model)
+ }
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/JobInfoProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/JobInfoProvider.kt
new file mode 100644
index 0000000..0b59425
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/JobInfoProvider.kt
@@ -0,0 +1,62 @@
+/**
+ * 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.providers
+
+import android.app.job.JobInfo
+import android.app.job.JobInfo.NETWORK_TYPE_ANY
+import android.app.job.JobInfo.NETWORK_TYPE_UNMETERED
+import android.content.ComponentName
+import android.content.Context
+import android.os.PersistableBundle
+
+interface JobInfoProvider {
+
+ fun createCheckJob(
+ id: Int,
+ onlyUnmeteredNetwork: Boolean,
+ delayMs: Long,
+ extras: PersistableBundle,
+ target: Class<*>
+ ): JobInfo
+}
+
+class RealJobInfoProvider(
+ private val context: Context
+) : JobInfoProvider {
+
+ // Note: we don't use the periodic feature of JobScheduler because it requires a
+ // minimum of 15 minutes between each execution which may not be what's requested by the
+ // user of the app.
+ override fun createCheckJob(
+ id: Int,
+ onlyUnmeteredNetwork: Boolean,
+ delayMs: Long,
+ extras: PersistableBundle,
+ target: Class<*>
+ ): JobInfo {
+ val component = ComponentName(context, target)
+ val networkType = if (onlyUnmeteredNetwork) {
+ NETWORK_TYPE_UNMETERED
+ } else {
+ NETWORK_TYPE_ANY
+ }
+ return JobInfo.Builder(id, component)
+ .setRequiredNetworkType(networkType)
+ .setMinimumLatency(delayMs)
+ .setExtras(extras)
+ .build()
+ }
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationChannelProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationChannelProvider.kt
new file mode 100644
index 0000000..e31ef04
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationChannelProvider.kt
@@ -0,0 +1,54 @@
+/**
+ * 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.providers
+
+import android.annotation.TargetApi
+import android.app.NotificationChannel
+import android.os.Build.VERSION_CODES
+
+/** @author Aidan Follestad (@afollestad) */
+interface NotificationChannelProvider {
+
+ /** @return null if the device doesn't have Android O. */
+ fun create(
+ id: String,
+ title: String,
+ description: String,
+ importance: Int
+ ): NotificationChannel?
+}
+
+/** @author Aidan Follestad (@afollestad) */
+class RealNotificationChannelProvider(
+ private val sdkProvider: SdkProvider
+) : NotificationChannelProvider {
+
+ @TargetApi(VERSION_CODES.O)
+ override fun create(
+ id: String,
+ title: String,
+ description: String,
+ importance: Int
+ ): NotificationChannel? {
+ if (!sdkProvider.hasOreo()) {
+ return null
+ }
+ return NotificationChannel(id, title, importance)
+ .apply {
+ this.description = description
+ }
+ }
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationProvider.kt
new file mode 100644
index 0000000..391fcb4
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/NotificationProvider.kt
@@ -0,0 +1,66 @@
+/**
+ * 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.providers
+
+import android.app.Notification
+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) */
+interface NotificationProvider {
+
+ fun create(
+ channelId: String,
+ title: String,
+ content: String,
+ intent: PendingIntent,
+ smallIcon: Int,
+ largeIcon: Bitmap? = null
+ ): Notification
+}
+
+/** @author Aidan Follestad (@afollestad) */
+class RealNotificationProvider(
+ private val context: Context
+) : NotificationProvider {
+
+ override fun create(
+ channelId: String,
+ title: String,
+ content: String,
+ intent: PendingIntent,
+ smallIcon: Int,
+ largeIcon: Bitmap?
+ ): Notification {
+ return NotificationCompat.Builder(context, channelId)
+ .setContentTitle(title)
+ .setContentText(content)
+ .setContentIntent(intent)
+ .setSmallIcon(smallIcon)
+ .setLargeIcon(largeIcon)
+ .setAutoCancel(true)
+ .setDefaults(DEFAULT_VIBRATE)
+ .setStyle(
+ BigTextStyle()
+ .bigText(content)
+ )
+ .build()
+ }
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/SdkProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/SdkProvider.kt
new file mode 100644
index 0000000..a8ec0a9
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/SdkProvider.kt
@@ -0,0 +1,31 @@
+/**
+ * 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.providers
+
+import android.os.Build.VERSION.SDK_INT
+import android.os.Build.VERSION_CODES.O
+
+/** @author Aidan Follestad (@afollestad) */
+interface SdkProvider {
+
+ fun hasOreo(): Boolean
+}
+
+/** @author Aidan Follestad (@afollestad) */
+class RealSdkProvider : SdkProvider {
+
+ override fun hasOreo() = SDK_INT >= O
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/providers/StringProvider.kt b/common/src/main/java/com/afollestad/nocknock/utilities/providers/StringProvider.kt
new file mode 100644
index 0000000..1d24030
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/providers/StringProvider.kt
@@ -0,0 +1,35 @@
+/**
+ * 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.providers
+
+import android.content.Context
+import androidx.annotation.StringRes
+
+/** @author Aidan Follestad (@afollestad) */
+interface StringProvider {
+
+ fun get(@StringRes res: Int): String
+}
+
+/** @author Aidan Follestad (@afollestad) */
+class RealStringProvider(
+ private val context: Context
+) : StringProvider {
+
+ override fun get(res: Int): String {
+ return context.resources.getString(res)
+ }
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/rx/RxLifecycleExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/rx/RxLifecycleExt.kt
new file mode 100644
index 0000000..a306aad
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/rx/RxLifecycleExt.kt
@@ -0,0 +1,58 @@
+/**
+ * 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.utilities.rx
+
+import androidx.lifecycle.Lifecycle.Event.ON_DESTROY
+import androidx.lifecycle.Lifecycle.State.DESTROYED
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.OnLifecycleEvent
+import io.reactivex.disposables.Disposable
+
+/** @author Aidan Follestad (afollestad) */
+class LifecycleAwareDisposable(
+ private val disposable: Disposable
+) : LifecycleObserver {
+
+ @OnLifecycleEvent(ON_DESTROY)
+ fun dispose() = disposable.dispose()
+}
+
+/**
+ * Wraps [disposable] so that it is disposed of when the receiving [LifecycleOwner]
+ * is destroyed.
+ *
+ * @author Aidan Follestad (afollestad)
+ */
+fun LifecycleOwner.ownRx(disposable: Disposable) {
+ if (this.lifecycle.currentState == DESTROYED) {
+ disposable.dispose()
+ return
+ }
+ this.lifecycle.addObserver(LifecycleAwareDisposable(disposable))
+}
+
+/**
+ * Attaches the receiving [Disposable] so that it is disposed of when [lifecycleOwner]
+ * is destroyed.
+ *
+ * @author Aidan Follestad (afollestad)
+ */
+fun Disposable.attachLifecycle(lifecycleOwner: LifecycleOwner) {
+ lifecycleOwner.ownRx(this)
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ui/ContextExt.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ui/ContextExt.kt
new file mode 100644
index 0000000..5b09b3d
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ui/ContextExt.kt
@@ -0,0 +1,30 @@
+/**
+ * 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.ui
+
+import android.content.Context
+import android.widget.Toast
+
+private var toast: Toast? = null
+
+/** Shows a toast in the receiving context, cancelling any previous. */
+fun Context.toast(message: Int) {
+ toast?.cancel()
+ toast = Toast.makeText(this, message, Toast.LENGTH_LONG)
+ .apply {
+ show()
+ }
+}
diff --git a/common/src/main/java/com/afollestad/nocknock/utilities/ui/DebouncedOnClickListener.kt b/common/src/main/java/com/afollestad/nocknock/utilities/ui/DebouncedOnClickListener.kt
new file mode 100644
index 0000000..bfb7a86
--- /dev/null
+++ b/common/src/main/java/com/afollestad/nocknock/utilities/ui/DebouncedOnClickListener.kt
@@ -0,0 +1,55 @@
+/**
+ * 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.ui
+
+import android.view.View
+import java.lang.System.currentTimeMillis
+
+private const val DEFAULT_DEBOUNCE_INTERVAL = 750L
+
+/** @author Aidan Follestad (@afollestad) */
+abstract class DebouncedOnClickListener(
+ private val delayBetweenClicks: Long = DEFAULT_DEBOUNCE_INTERVAL
+) : View.OnClickListener {
+
+ private var lastClickTimestamp = -1L
+
+ @Deprecated(
+ message = "onDebouncedClick should be overridden instead.",
+ replaceWith = ReplaceWith("onDebouncedClick(v)")
+ )
+ override fun onClick(v: View) {
+ val now = currentTimeMillis()
+ if (lastClickTimestamp == -1L || now >= (lastClickTimestamp + delayBetweenClicks)) {
+ onDebouncedClick(v)
+ }
+ lastClickTimestamp = now
+ }
+
+ abstract fun onDebouncedClick(v: View)
+}
+
+/** @author Aidan Follestad (@afollestad) */
+fun View.onDebouncedClick(
+ delayBetweenClicks: Long = DEFAULT_DEBOUNCE_INTERVAL,
+ click: (view: View) -> Unit
+) {
+ setOnClickListener(object : DebouncedOnClickListener(delayBetweenClicks) {
+ override fun onDebouncedClick(v: View) {
+ click(v)
+ }
+ })
+}
diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml
new file mode 100644
index 0000000..fda664e
--- /dev/null
+++ b/common/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+ Checks Disabled
+ Automatic Checks Disabled
+
diff --git a/common/src/test/java/com/afollestad/nocknock/utilities/livedata/DistinctTest.kt b/common/src/test/java/com/afollestad/nocknock/utilities/livedata/DistinctTest.kt
new file mode 100644
index 0000000..465a085
--- /dev/null
+++ b/common/src/test/java/com/afollestad/nocknock/utilities/livedata/DistinctTest.kt
@@ -0,0 +1,47 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.utilities.livedata
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.MutableLiveData
+import org.junit.Rule
+import org.junit.Test
+
+/** @author Aidan Follestad (@afollestad) */
+class DistinctTest {
+
+ @Rule @JvmField val rule = InstantTaskExecutorRule()
+
+ @Test fun filterLastValues() {
+ val data = MutableLiveData()
+ val distinct = data.distinct()
+ .test()
+
+ data.postValue("Hello")
+ data.postValue("Hello")
+
+ data.postValue("Hi")
+ data.postValue("Hi")
+
+ data.postValue("Hello")
+
+ distinct.assertValues(
+ "Hello",
+ "Hi",
+ "Hello"
+ )
+ }
+}
diff --git a/common/src/test/java/com/afollestad/nocknock/utilities/livedata/ZipTest.kt b/common/src/test/java/com/afollestad/nocknock/utilities/livedata/ZipTest.kt
new file mode 100644
index 0000000..0cc57da
--- /dev/null
+++ b/common/src/test/java/com/afollestad/nocknock/utilities/livedata/ZipTest.kt
@@ -0,0 +1,123 @@
+/**
+ * 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.livedata
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.lifecycle.MutableLiveData
+import org.junit.Rule
+import org.junit.Test
+
+/** @author Aidan Follestad (@afollestad) */
+class ZipTest {
+
+ @Rule @JvmField val rule = InstantTaskExecutorRule()
+
+ @Test fun test_withDistinct() {
+ val data1 = MutableLiveData()
+ val data2 = MutableLiveData()
+ val zipped = zip(data1, data2, true)
+ .test()
+
+ data1.postValue("Hello")
+ data2.postValue(24)
+ zipped.assertValues(Pair("Hello", 24))
+
+ data1.postValue("Hello")
+ data2.postValue(24)
+ zipped.assertNoValues()
+ }
+
+ @Test fun test_noDistinct() {
+ val data1 = MutableLiveData()
+ val data2 = MutableLiveData()
+ val zipped = zip(data1, data2, false)
+ .test()
+
+ data1.postValue("Hello")
+ data2.postValue(24)
+ zipped.assertValues(Pair("Hello", 24))
+
+ data1.postValue("Hi")
+ data2.postValue(24)
+ zipped.assertValues(Pair("Hi", 24))
+ }
+
+ @Test fun test_noDistinct_resetAfterEmission() {
+ val data1 = MutableLiveData()
+ val data2 = MutableLiveData()
+ val zipped = zip(data1, data2, false, true)
+ .test()
+
+ data1.postValue("Hello")
+ data2.postValue(24)
+ zipped.assertValues(Pair("Hello", 24))
+
+ data1.postValue("Hi")
+ data2.postValue(50)
+ zipped.assertValues(Pair("Hi", 50))
+ }
+
+ @Test fun test_withDistinct_customZipper() {
+ val data1 = MutableLiveData()
+ val data2 = MutableLiveData()
+ val zipped = zip(data1, data2, true,
+ zipper = { left, right ->
+ "$left $right"
+ }).test()
+
+ data1.postValue("Hello")
+ data2.postValue(24)
+ zipped.assertValues("Hello 24")
+
+ data1.postValue("Hello")
+ data2.postValue(24)
+ zipped.assertNoValues()
+ }
+
+ @Test fun test_noDistinct_customZipper() {
+ val data1 = MutableLiveData()
+ val data2 = MutableLiveData()
+ val zipped = zip(data1, data2, false,
+ zipper = { left, right ->
+ "$left $right"
+ }).test()
+
+ data1.postValue("Hello")
+ data2.postValue(24)
+ zipped.assertValues("Hello 24")
+
+ data1.postValue("Hi")
+ data2.postValue(24)
+ zipped.assertValues("Hi 24")
+ }
+
+ @Test fun test_noDistinct_customZipper_resetAfterEmission() {
+ val data1 = MutableLiveData()
+ val data2 = MutableLiveData()
+ val zipped = zip(data1, data2, false, true,
+ zipper = { left, right ->
+ "$left $right"
+ }).test()
+
+ data1.postValue("Hello")
+ data2.postValue(24)
+ zipped.assertValues("Hello 24")
+
+ data1.postValue("Hi")
+ data2.postValue(50)
+ zipped.assertValues("Hi 50")
+ }
+}
diff --git a/data/.gitignore b/data/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/data/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/data/build.gradle b/data/build.gradle
new file mode 100644
index 0000000..e413d4c
--- /dev/null
+++ b/data/build.gradle
@@ -0,0 +1,37 @@
+apply from: '../dependencies.gradle'
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
+
+android {
+ compileSdkVersion versions.compileSdk
+
+ defaultConfig {
+ minSdkVersion versions.minSdk
+ targetSdkVersion versions.compileSdk
+ versionCode versions.publishVersionCode
+ versionName versions.publishVersion
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ packagingOptions {
+ exclude 'META-INF/atomicfu.kotlin_module'
+ }
+}
+
+dependencies {
+ implementation project(':common')
+
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
+
+ api 'androidx.room:room-runtime:' + versions.room
+ kapt 'androidx.room:room-compiler:' + versions.room
+
+ androidTestImplementation 'androidx.test:runner:' + versions.androidxTestRunner
+ androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner
+ androidTestImplementation 'androidx.test:core:' + versions.androidxTest
+ androidTestImplementation 'com.google.truth:truth:' + versions.truth
+}
+
+apply from: '../spotless.gradle'
\ No newline at end of file
diff --git a/data/src/androidTest/AndroidManifest.xml b/data/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..c83c51d
--- /dev/null
+++ b/data/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt
new file mode 100644
index 0000000..e7d4400
--- /dev/null
+++ b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt
@@ -0,0 +1,554 @@
+/**
+ * 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("RemoveEmptyPrimaryConstructor")
+
+package com.afollestad.nocknock.data
+
+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
+import com.afollestad.nocknock.data.model.Status.OK
+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.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.IOException
+import java.lang.System.currentTimeMillis
+
+/** @author Aidan Follestad (@afollestad) */
+@RunWith(AndroidJUnit4::class)
+class AppDatabaseTest() {
+
+ private lateinit var db: AppDatabase
+ private lateinit var sitesDao: SiteDao
+ 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()
+ db = inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
+ sitesDao = db.siteDao()
+ settingsDao = db.siteSettingsDao()
+ resultsDao = db.validationResultsDao()
+ retryDao = db.retryPolicyDao()
+ headerDao = db.headerDao()
+ }
+
+ @After
+ @Throws(IOException::class)
+ fun destroy() = db.close()
+
+ // SiteDao
+
+ @Test fun site_insert_and_get_all() {
+ val model1 = Site(
+ name = "Test 1",
+ url = "https://test1.com",
+ tags = "",
+ settings = null,
+ lastResult = null,
+ retryPolicy = null,
+ headers = emptyList()
+ )
+ val newId1 = sitesDao.insert(model1)
+ assertThat(newId1).isGreaterThan(0)
+
+ val model2 = Site(
+ name = "Test 2",
+ url = "https://test2.com",
+ tags = "",
+ settings = null,
+ lastResult = null,
+ retryPolicy = null,
+ headers = emptyList()
+ )
+ val newId2 = sitesDao.insert(model2)
+ assertThat(newId2).isGreaterThan(newId1)
+
+ val models = sitesDao.all()
+ assertThat(models.size).isEqualTo(2)
+ assertThat(models[0]).isEqualTo(model1.copy(id = newId1))
+ assertThat(models[1]).isEqualTo(model2.copy(id = newId2))
+ }
+
+ @Test fun site_insert_and_get_one() {
+ val model = Site(
+ name = "Test",
+ url = "https://test.com",
+ tags = "",
+ settings = null,
+ lastResult = null,
+ retryPolicy = null,
+ headers = emptyList()
+ )
+ val newId = sitesDao.insert(model)
+ assertThat(newId).isGreaterThan(0)
+
+ val models = sitesDao.all()
+ assertThat(models.single()).isEqualTo(model.copy(id = newId))
+ }
+
+ @Test fun site_insert_and_update() {
+ val initialModel = Site(
+ name = "Test 1",
+ url = "https://test1.com",
+ tags = "",
+ settings = null,
+ lastResult = null,
+ retryPolicy = null,
+ headers = emptyList()
+ )
+ val newId = sitesDao.insert(initialModel)
+ assertThat(newId).isGreaterThan(0)
+
+ val insertedModel = sitesDao.all()
+ .single()
+ val updatedModel = insertedModel.copy(
+ name = "Test 2",
+ url = "https://hi.com"
+ )
+ assertThat(sitesDao.update(updatedModel)).isEqualTo(1)
+
+ val finalModel = sitesDao.all()
+ .single()
+ assertThat(finalModel).isNotEqualTo(initialModel.copy(id = newId))
+ }
+
+ @Test fun site_insert_and_delete() {
+ val model1 = Site(
+ name = "Test 1",
+ url = "https://test1.com",
+ tags = "",
+ settings = null,
+ lastResult = null,
+ retryPolicy = null,
+ headers = emptyList()
+ )
+ val newId1 = sitesDao.insert(model1)
+ assertThat(newId1).isGreaterThan(0)
+
+ val model2 = Site(
+ name = "Test 2",
+ url = "https://test2.com",
+ tags = "",
+ settings = null,
+ lastResult = null,
+ retryPolicy = null,
+ headers = emptyList()
+ )
+ val newId2 = sitesDao.insert(model2)
+ assertThat(newId2).isGreaterThan(newId1)
+
+ val models1 = sitesDao.all()
+ sitesDao.delete(models1[0])
+
+ val models2 = sitesDao.all()
+ assertThat(models2.single()).isEqualTo(models1[1])
+ }
+
+ // SiteSettingsDao
+
+ @Test fun settings_insert_and_forSite() {
+ val model = SiteSettings(
+ siteId = 1,
+ validationIntervalMs = 60000,
+ validationMode = STATUS_CODE,
+ validationArgs = null,
+ disabled = false,
+ networkTimeout = 10000,
+ certificate = null
+ )
+ val newId = settingsDao.insert(model)
+ assertThat(newId).isEqualTo(1)
+
+ val finalModel = settingsDao.forSite(newId)
+ .single()
+ assertThat(finalModel).isEqualTo(model.copy(siteId = newId))
+ }
+
+ @Test fun settings_update() {
+ settingsDao.insert(
+ SiteSettings(
+ siteId = 1,
+ validationIntervalMs = 60000,
+ validationMode = STATUS_CODE,
+ validationArgs = null,
+ disabled = false,
+ networkTimeout = 10000,
+ certificate = null
+ )
+ )
+
+ val insertedModel = settingsDao.forSite(1)
+ .single()
+ val updatedModel = insertedModel.copy(
+ validationIntervalMs = 10000,
+ validationMode = TERM_SEARCH,
+ validationArgs = "test",
+ disabled = false,
+ networkTimeout = 1000
+ )
+ assertThat(settingsDao.update(updatedModel)).isEqualTo(1)
+
+ val finalModel = settingsDao.forSite(1)
+ .single()
+ assertThat(finalModel).isEqualTo(updatedModel)
+ }
+
+ @Test fun settings_delete() {
+ settingsDao.insert(
+ SiteSettings(
+ siteId = 1,
+ validationIntervalMs = 60000,
+ validationMode = STATUS_CODE,
+ validationArgs = null,
+ disabled = false,
+ networkTimeout = 10000,
+ certificate = null
+ )
+ )
+
+ val insertedModel = settingsDao.forSite(1)
+ .single()
+ settingsDao.delete(insertedModel)
+ assertThat(settingsDao.forSite(1)).isEmpty()
+ }
+
+ // ValidationResultsDao
+
+ @Test fun validation_insert_and_forSite() {
+ val model = ValidationResult(
+ siteId = 1,
+ timestampMs = currentTimeMillis(),
+ status = ERROR,
+ reason = "Oh no"
+ )
+ val newId = resultsDao.insert(model)
+ assertThat(newId).isEqualTo(1)
+
+ val finalModel = resultsDao.forSite(newId)
+ .single()
+ assertThat(finalModel).isEqualTo(model.copy(siteId = newId))
+ }
+
+ @Test fun validation_update() {
+ resultsDao.insert(
+ ValidationResult(
+ siteId = 1,
+ timestampMs = currentTimeMillis(),
+ status = ERROR,
+ reason = "Oh no"
+ )
+ )
+
+ val insertedModel = resultsDao.forSite(1)
+ .single()
+ val updatedModel = insertedModel.copy(
+ timestampMs = currentTimeMillis() + 1000,
+ status = OK,
+ reason = null
+ )
+ assertThat(resultsDao.update(updatedModel)).isEqualTo(1)
+
+ val finalModel = resultsDao.forSite(1)
+ .single()
+ assertThat(finalModel).isEqualTo(updatedModel)
+ }
+
+ @Test fun validation_delete() {
+ resultsDao.insert(
+ ValidationResult(
+ siteId = 1,
+ timestampMs = currentTimeMillis(),
+ status = ERROR,
+ reason = "Oh no"
+ )
+ )
+
+ val insertedModel = resultsDao.forSite(1)
+ .single()
+ resultsDao.delete(insertedModel)
+ assertThat(resultsDao.forSite(1)).isEmpty()
+ }
+
+ // RetryPolicyDao
+
+ @Test fun retryPolicy_insert_and_forSite() {
+ val model = RetryPolicy(
+ siteId = 1,
+ count = 3,
+ minutes = 6
+ )
+ val newId = retryDao.insert(model)
+ assertThat(newId).isEqualTo(1)
+
+ val finalModel = retryDao.forSite(newId)
+ .single()
+ assertThat(finalModel).isEqualTo(model.copy(siteId = newId))
+ }
+
+ @Test fun retryPolicy_update() {
+ retryDao.insert(
+ RetryPolicy(
+ siteId = 1,
+ count = 3,
+ minutes = 6
+ )
+ )
+
+ val insertedModel = retryDao.forSite(1)
+ .single()
+ val updatedModel = insertedModel.copy(
+ count = 4,
+ minutes = 8
+ )
+ assertThat(retryDao.update(updatedModel)).isEqualTo(1)
+
+ val finalModel = retryDao.forSite(1)
+ .single()
+ assertThat(finalModel).isEqualTo(updatedModel)
+ }
+
+ @Test fun retryPolicy_delete() {
+ retryDao.insert(
+ RetryPolicy(
+ siteId = 1,
+ count = 3,
+ minutes = 6
+ )
+ )
+
+ val insertedModel = retryDao.forSite(1)
+ .single()
+ retryDao.delete(insertedModel)
+ 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() {
+ db.putSite(MOCK_MODEL_1)
+ db.putSite(MOCK_MODEL_2)
+ db.putSite(MOCK_MODEL_3)
+
+ val allSites = db.allSites()
+ assertThat(allSites.size).isEqualTo(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() {
+ db.putSite(MOCK_MODEL_1)
+ db.putSite(MOCK_MODEL_2)
+ db.putSite(MOCK_MODEL_3)
+ val allSites = db.allSites()
+
+ val site = db.getSite(2)
+ assertThat(site).isEqualTo(allSites[1])
+ }
+
+ @Test fun extension_put_updateSite() {
+ db.putSite(MOCK_MODEL_1)
+ db.putSite(MOCK_MODEL_2)
+ db.putSite(MOCK_MODEL_3)
+ val modelToUpdate = db.allSites()[1]
+
+ val updatedSettings = modelToUpdate.settings!!.copy(
+ validationIntervalMs = 1,
+ validationMode = JAVASCRIPT,
+ validationArgs = "throw 'Hello World'",
+ disabled = false,
+ networkTimeout = 50
+ )
+ val updatedValidationResult = modelToUpdate.lastResult!!.copy(
+ timestampMs = currentTimeMillis() + 10,
+ status = ERROR,
+ reason = "Oh no"
+ )
+ val updatedRetryPolicy = modelToUpdate.retryPolicy!!.copy(
+ 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,
+ headers = updatedHeaders
+ )
+
+ db.updateSite(updatedModel)
+
+ val finalSite = db.getSite(modelToUpdate.id)!!
+ 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)
+ }
+
+ @Test fun extension_put_and_deleteSite() {
+ db.putSite(MOCK_MODEL_1)
+ db.putSite(MOCK_MODEL_2)
+ db.putSite(MOCK_MODEL_3)
+ val allSites = db.allSites()
+
+ db.deleteSite(allSites[1])
+
+ val remainingSettings = settingsDao.all()
+ assertThat(remainingSettings.size).isEqualTo(2)
+ assertThat(remainingSettings[0]).isEqualTo(allSites[0].settings!!)
+ assertThat(remainingSettings[1]).isEqualTo(allSites[2].settings!!)
+
+ val remainingResults = resultsDao.all()
+ assertThat(remainingResults.size).isEqualTo(2)
+ assertThat(remainingResults[0]).isEqualTo(allSites[0].lastResult!!)
+ assertThat(remainingResults[1]).isEqualTo(allSites[2].lastResult!!)
+
+ val remainingRetryPolicies = retryDao.all()
+ assertThat(remainingRetryPolicies.size).isEqualTo(2)
+ assertThat(remainingRetryPolicies[0]).isEqualTo(allSites[0].retryPolicy!!)
+ assertThat(remainingRetryPolicies[1]).isEqualTo(allSites[2].retryPolicy!!)
+
+ val remainingHeaders = headerDao.all()
+ assertThat(remainingHeaders.size).isEqualTo(4)
+ assertThat(remainingHeaders[0]).isEqualTo(allSites[0].headers.first())
+ assertThat(remainingHeaders[1]).isEqualTo(allSites[0].headers.last())
+ assertThat(remainingHeaders[2]).isEqualTo(allSites[2].headers.first())
+ assertThat(remainingHeaders[3]).isEqualTo(allSites[2].headers.last())
+ }
+}
diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt
new file mode 100644
index 0000000..2866b50
--- /dev/null
+++ b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt
@@ -0,0 +1,81 @@
+/**
+ * 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 com.afollestad.nocknock.data.model.Header
+import com.afollestad.nocknock.data.model.RetryPolicy
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.SiteSettings
+import com.afollestad.nocknock.data.model.Status
+import com.afollestad.nocknock.data.model.Status.OK
+import com.afollestad.nocknock.data.model.ValidationMode
+import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
+import com.afollestad.nocknock.data.model.ValidationResult
+import java.lang.System.currentTimeMillis
+
+fun fakeSettingsModel(
+ id: Long,
+ validationMode: ValidationMode = STATUS_CODE
+) = SiteSettings(
+ siteId = id,
+ validationIntervalMs = 600000,
+ validationMode = validationMode,
+ validationArgs = null,
+ disabled = false,
+ networkTimeout = 10000,
+ certificate = null
+)
+
+fun fakeResultModel(
+ id: Long,
+ status: Status = OK,
+ reason: String? = null
+) = ValidationResult(
+ siteId = id,
+ status = status,
+ reason = reason,
+ timestampMs = currentTimeMillis()
+)
+
+fun fakeRetryPolicy(
+ id: Long,
+ count: Int = 3,
+ minutes: Int = 6
+) = RetryPolicy(
+ siteId = id,
+ count = count,
+ minutes = minutes
+)
+
+fun fakeHeaders(siteId: Long) = 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),
+ headers = fakeHeaders(id)
+)
+
+val MOCK_MODEL_1 = fakeModel(1)
+val MOCK_MODEL_2 = fakeModel(2)
+val MOCK_MODEL_3 = fakeModel(3)
diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..465b811
--- /dev/null
+++ b/data/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
diff --git a/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt
new file mode 100644
index 0000000..47687ba
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt
@@ -0,0 +1,191 @@
+/**
+ * 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.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
+
+/** @author Aidan Follestad (@afollestad) */
+@Database(
+ entities = [
+ Header::class,
+ RetryPolicy::class,
+ ValidationResult::class,
+ SiteSettings::class,
+ Site::class
+ ],
+ version = 5,
+ exportSchema = false
+)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+
+ abstract fun siteDao(): SiteDao
+
+ abstract fun siteSettingsDao(): SiteSettingsDao
+
+ abstract fun validationResultsDao(): ValidationResultsDao
+
+ abstract fun retryPolicyDao(): RetryPolicyDao
+
+ abstract fun headerDao(): HeaderDao
+}
+
+/**
+ * Gets all sites and maps their settings and last validation results.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+fun AppDatabase.allSites(): List {
+ return siteDao().all()
+ .map {
+ val settings = siteSettingsDao().forSite(it.id)
+ .single()
+ val lastResult = validationResultsDao().forSite(it.id)
+ .singleOrNull()
+ val retryPolicy = retryPolicyDao().forSite(it.id)
+ .singleOrNull()
+ val headers = headerDao().forSite(it.id)
+ return@map it.copy(
+ settings = settings,
+ lastResult = lastResult,
+ retryPolicy = retryPolicy,
+ headers = headers
+ )
+ }
+}
+
+/**
+ * Gets a single site and maps its settings and last validation result.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+fun AppDatabase.getSite(id: Long): Site? {
+ val result = siteDao().one(id)
+ .singleOrNull() ?: return null
+ val settings = siteSettingsDao().forSite(id)
+ .single()
+ val lastResult = validationResultsDao().forSite(id)
+ .singleOrNull()
+ val retryPolicy = retryPolicyDao().forSite(id)
+ .singleOrNull()
+ val headers = headerDao().forSite(id)
+ return result.copy(
+ settings = settings,
+ lastResult = lastResult,
+ retryPolicy = retryPolicy,
+ headers = headers
+ )
+}
+
+/**
+ * Inserts a site along with its settings and last result into the database.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+fun AppDatabase.putSite(site: Site): Site {
+ val settings = site.settings ?: throw IllegalArgumentException("Settings cannot be null.")
+ val newId = siteDao().insert(site)
+ val settingsWithSiteId = settings.copy(siteId = newId)
+ val lastResultWithSiteId = site.lastResult?.copy(siteId = newId)
+ val retryPolicyWithSiteId = site.retryPolicy?.copy(siteId = newId)
+ 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,
+ lastResult = lastResultWithSiteId,
+ retryPolicy = retryPolicyWithSiteId,
+ headers = headersWithSiteId
+ )
+}
+
+/**
+ * Updates a site, along with its settings and last result, in the database.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+fun AppDatabase.updateSite(site: Site) {
+ siteDao().update(site)
+
+ val settings = site.settings?.copy(siteId = site.id)
+ if (settings != null) {
+ val existing = siteSettingsDao().forSite(site.id)
+ .singleOrNull()
+ if (existing != null) {
+ siteSettingsDao().update(settings)
+ } else {
+ siteSettingsDao().insert(settings)
+ }
+ }
+
+ val lastResult = site.lastResult?.copy(siteId = site.id)
+ if (lastResult != null) {
+ val existing = validationResultsDao().forSite(site.id)
+ .singleOrNull()
+ if (existing != null) {
+ validationResultsDao().update(lastResult)
+ } else {
+ validationResultsDao().insert(lastResult)
+ }
+ }
+
+ val retryPolicy = site.retryPolicy?.copy(siteId = site.id)
+ if (retryPolicy != null) {
+ val existing = retryPolicyDao().forSite(site.id)
+ .singleOrNull()
+ if (existing != null) {
+ retryPolicyDao().update(retryPolicy)
+ } else {
+ 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))
+ }
+}
+
+/**
+ * Deletes a site along with its settings and last result from the database.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+fun AppDatabase.deleteSite(site: Site) {
+ site.settings?.let { siteSettingsDao().delete(it) }
+ site.lastResult?.let { validationResultsDao().delete(it) }
+ site.retryPolicy?.let { retryPolicyDao().delete(it) }
+ if (site.headers.any { it.id == 0L }) {
+ throw IllegalStateException("Cannot delete header with ID = 0.")
+ }
+ headerDao().delete(site.headers)
+ siteDao().delete(site)
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt b/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt
new file mode 100644
index 0000000..0b158b8
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt
@@ -0,0 +1,71 @@
+/**
+ * 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.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+/**
+ * Migrates the database from version 1 to 2.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+class Database1to2Migration : Migration(1, 2) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ "CREATE TABLE IF NOT EXISTS `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes INTEGER NOT NULL, lastTryTimestamp INTEGER NOT NULL, triesLeft INTEGER NOT NULL)"
+ )
+ }
+}
+
+/**
+ * Migrates the database from version 2 to 3.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+class Database2to3Migration : Migration(2, 3) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL("ALTER TABLE `sites` ADD COLUMN tags TEXT NOT NULL DEFAULT ''")
+ }
+}
+
+/**
+ * Migrates the database from version 3 to 4.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+class Database3to4Migration : Migration(3, 4) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL(
+ "CREATE TABLE IF NOT EXISTS `headers` (id INTEGER PRIMARY KEY NOT NULL, siteId INTEGER NOT NULL, `key` TEXT NOT NULL, value TEXT NOT NULL)"
+ )
+ }
+}
+
+/**
+ * Migrates the database from version 4 to 5.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+class Database4to5Migration : Migration(4, 5) {
+
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL("ALTER TABLE `site_settings` ADD COLUMN certificate TEXT")
+ }
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt b/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt
new file mode 100644
index 0000000..c34f0cb
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt
@@ -0,0 +1,47 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.data
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy.FAIL
+import androidx.room.Query
+import androidx.room.Update
+import com.afollestad.nocknock.data.model.Header
+
+/** @author Aidan Follestad (@afollestad) */
+@Dao
+interface HeaderDao {
+
+ @Query("SELECT * FROM headers ORDER BY siteId ASC")
+ fun all(): List
+
+ @Query("SELECT * FROM headers WHERE siteId = :siteId")
+ fun forSite(siteId: Long): List
+
+ @Insert(onConflict = FAIL)
+ fun insert(headers: Header): Long
+
+ @Insert(onConflict = FAIL)
+ fun insert(headers: List): List
+
+ @Update(onConflict = FAIL)
+ fun update(header: Header): Int
+
+ @Delete
+ fun delete(headers: List): Int
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/RetryPolicyDao.kt b/data/src/main/java/com/afollestad/nocknock/data/RetryPolicyDao.kt
new file mode 100644
index 0000000..e130fed
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/RetryPolicyDao.kt
@@ -0,0 +1,44 @@
+/**
+ * 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.RetryPolicy
+
+/** @author Aidan Follestad (@afollestad) */
+@Dao
+interface RetryPolicyDao {
+
+ @Query("SELECT * FROM retry_policies ORDER BY siteId ASC")
+ fun all(): List
+
+ @Query("SELECT * FROM retry_policies WHERE siteId = :siteId LIMIT 1")
+ fun forSite(siteId: Long): List
+
+ @Insert(onConflict = FAIL)
+ fun insert(policy: RetryPolicy): Long
+
+ @Update(onConflict = FAIL)
+ fun update(policy: RetryPolicy): Int
+
+ @Delete
+ fun delete(policy: RetryPolicy): Int
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/SiteDao.kt b/data/src/main/java/com/afollestad/nocknock/data/SiteDao.kt
new file mode 100644
index 0000000..135a8f1
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/SiteDao.kt
@@ -0,0 +1,44 @@
+/**
+ * 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.Site
+
+/** @author Aidan Follestad (@afollestad) */
+@Dao
+interface SiteDao {
+
+ @Query("SELECT * FROM sites ORDER BY name ASC")
+ fun all(): List
+
+ @Query("SELECT * FROM sites WHERE id = :id LIMIT 1")
+ fun one(id: Long): List
+
+ @Insert(onConflict = FAIL)
+ fun insert(site: Site): Long
+
+ @Update(onConflict = FAIL)
+ fun update(site: Site): Int
+
+ @Delete
+ fun delete(site: Site): Int
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/SiteSettingsDao.kt b/data/src/main/java/com/afollestad/nocknock/data/SiteSettingsDao.kt
new file mode 100644
index 0000000..b11f418
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/SiteSettingsDao.kt
@@ -0,0 +1,44 @@
+/**
+ * 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.SiteSettings
+
+/** @author Aidan Follestad (@afollestad) */
+@Dao
+interface SiteSettingsDao {
+
+ @Query("SELECT * FROM site_settings ORDER BY siteId ASC")
+ fun all(): List
+
+ @Query("SELECT * FROM site_settings WHERE siteId = :siteId LIMIT 1")
+ fun forSite(siteId: Long): List
+
+ @Insert(onConflict = FAIL)
+ fun insert(siteSetting: SiteSettings): Long
+
+ @Update(onConflict = FAIL)
+ fun update(siteSetting: SiteSettings): Int
+
+ @Delete
+ fun delete(siteSetting: SiteSettings): Int
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/ValidationResultsDao.kt b/data/src/main/java/com/afollestad/nocknock/data/ValidationResultsDao.kt
new file mode 100644
index 0000000..d6c81dc
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/ValidationResultsDao.kt
@@ -0,0 +1,44 @@
+/**
+ * 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.ValidationResult
+
+/** @author Aidan Follestad (@afollestad) */
+@Dao
+interface ValidationResultsDao {
+
+ @Query("SELECT * FROM validation_results ORDER BY siteId ASC")
+ fun all(): List
+
+ @Query("SELECT * FROM validation_results WHERE siteId = :siteId LIMIT 1")
+ fun forSite(siteId: Long): List
+
+ @Insert(onConflict = FAIL)
+ fun insert(siteSetting: ValidationResult): Long
+
+ @Update(onConflict = FAIL)
+ fun update(siteSetting: ValidationResult): Int
+
+ @Delete
+ fun delete(siteSetting: ValidationResult): Int
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Converters.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Converters.kt
new file mode 100644
index 0000000..b532829
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/Converters.kt
@@ -0,0 +1,42 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.data.model
+
+import androidx.room.TypeConverter
+
+/** @author Aidan Follestad (@afollestad) */
+class Converters {
+
+ @TypeConverter
+ fun fromStatus(status: Status): Int {
+ return status.value
+ }
+
+ @TypeConverter
+ fun toStatus(raw: Int): Status {
+ return Status.fromValue(raw)
+ }
+
+ @TypeConverter
+ fun fromValidationMode(mode: ValidationMode): Int {
+ return mode.value
+ }
+
+ @TypeConverter
+ fun toValidationMode(raw: Int): ValidationMode {
+ return ValidationMode.fromValue(raw)
+ }
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt
new file mode 100644
index 0000000..9460af0
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt
@@ -0,0 +1,42 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("unused")
+
+package com.afollestad.nocknock.data.model
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import java.io.Serializable
+
+/**
+ * Represents an HTTP header that is sent with a site's validation attempts.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+@Entity(tableName = "headers")
+data class Header(
+ /** The header's unique datrabase ID. */
+ @PrimaryKey(autoGenerate = true) var id: Long = 0,
+ /** The [Site] this header belong to. */
+ var siteId: Long = 0,
+ /** The header key/name. */
+ var key: String = "",
+ /** The header value. */
+ var value: String = ""
+) : Serializable {
+
+ constructor() : this(0, 0, "", "")
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/RetryPolicy.kt b/data/src/main/java/com/afollestad/nocknock/data/model/RetryPolicy.kt
new file mode 100644
index 0000000..a5c7831
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/RetryPolicy.kt
@@ -0,0 +1,70 @@
+/**
+ * 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 com.afollestad.nocknock.utilities.ext.MINUTE
+import java.io.Serializable
+
+/**
+ * Represents a site's retry policy, or how many times we
+ * retry in a certain timespan before considering a site to
+ * have a problem.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+@Entity(tableName = "retry_policies")
+data class RetryPolicy(
+ /** The [Site] these settings belong to. */
+ @PrimaryKey(autoGenerate = false) var siteId: Long = 0,
+ /** How many times we want to retry. */
+ var count: Int = 0,
+ /**
+ * In what amount of time (in minutes) we want
+ * to perform those retries.
+ */
+ var minutes: Int = 0,
+ /** The timestamp in milliseconds of the last attempt. */
+ var lastTryTimestamp: Long = 0,
+ /** How many retries we have left before considering the site to have problem. */
+ var triesLeft: Int = -1
+) : Serializable {
+
+ constructor() : this(0, 0, 0)
+
+ // Say we are trying 6 times in 3 minutes, that means times per minute = 2.
+ // Twice per minute means every 30 seconds.
+ // 30 seconds = 30 * 1000 or 30,000 milliseconds.
+ // 60,000 / 2 = 30,000.
+ fun interval(): Long {
+ if (count == 0 || minutes == 0) {
+ return -1
+ }
+ val timesPerMinute = count.toFloat() / minutes.toFloat()
+ return MINUTE / timesPerMinute.toSafeInt()
+ }
+
+ private fun Float.toSafeInt(): Int {
+ val intValue = toInt()
+ if (intValue == 0) {
+ return 1
+ }
+ return intValue
+ }
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt
new file mode 100644
index 0000000..98feedc
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt
@@ -0,0 +1,84 @@
+/**
+ * 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.model
+
+import androidx.room.Entity
+import androidx.room.Ignore
+import androidx.room.PrimaryKey
+import com.afollestad.nocknock.data.model.Status.WAITING
+import com.afollestad.nocknock.utilities.ext.timeString
+import com.afollestad.nocknock.utilities.providers.CanNotifyModel
+import java.lang.System.currentTimeMillis
+import kotlin.math.max
+
+/** @author Aidan Follestad (@afollestad) */
+@Entity(tableName = "sites")
+data class Site(
+ /** The site's unique ID. */
+ @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. */
+ 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?,
+ /** Request headers sent with this site's validation attempts. */
+ @Ignore var headers: List
+) : CanNotifyModel {
+
+ constructor() : this(0, "", "", "", null, null, null, emptyList())
+
+ override fun notifyId(): Int = id.toInt()
+
+ override fun notifyName(): String = name
+
+ override fun notifyTag(): String = url
+
+ override fun notifyDescription() = lastResult?.reason
+
+ fun intervalText(): String {
+ requireNotNull(settings) { "Settings not queried." }
+ val lastCheck = lastResult?.timestampMs ?: -1
+ val checkInterval = settings!!.validationIntervalMs
+ val now = System.currentTimeMillis()
+ val nextCheck = max(lastCheck, 0) + checkInterval
+ return (nextCheck - now).timeString()
+ }
+
+ fun withStatus(
+ status: Status? = null,
+ reason: String? = null,
+ timestamp: Long? = null
+ ): Site {
+ val newLastResult = lastResult?.copy(
+ status = status ?: lastResult!!.status,
+ reason = reason,
+ timestampMs = timestamp ?: lastResult!!.timestampMs
+ ) ?: ValidationResult(
+ siteId = this.id,
+ timestampMs = timestamp ?: currentTimeMillis(),
+ status = status ?: WAITING,
+ reason = reason
+ )
+ return this.copy(lastResult = newLastResult)
+ }
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt b/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt
new file mode 100644
index 0000000..bc73589
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt
@@ -0,0 +1,49 @@
+/**
+ * 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 com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
+import java.io.Serializable
+
+/**
+ * Represents the current user configuration for a [Site].
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+@Entity(tableName = "site_settings")
+data class SiteSettings(
+ /** The [Site] these settings belong to. */
+ @PrimaryKey(autoGenerate = false) var siteId: Long = 0,
+ /** How often a validation attempt is made, in milliseconds. */
+ var validationIntervalMs: Long,
+ /** The method of which is used to validate the [Site]. */
+ var validationMode: ValidationMode,
+ /** Args that are used for the [ValidationMode], e.g. a search term. */
+ var validationArgs: String?,
+ /** Whether or not the [Site] is enabled for automatic periodic checks. */
+ var disabled: Boolean,
+ /** The network response timeout for validation attempts. */
+ var networkTimeout: Int,
+ /** The Uri to a self signed certificate. */
+ var certificate: String?
+) : Serializable {
+
+ constructor() : this(0, 0, STATUS_CODE, null, false, 0, null)
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Status.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Status.kt
new file mode 100644
index 0000000..e6294ea
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/Status.kt
@@ -0,0 +1,57 @@
+/**
+ * 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.model
+
+import com.afollestad.nocknock.data.R.string
+import com.afollestad.nocknock.data.model.Status.CHECKING
+import com.afollestad.nocknock.data.model.Status.OK
+import com.afollestad.nocknock.data.model.Status.WAITING
+
+/**
+ * Represents the current status of a [Site] - or whether or not the
+ * site passed its most recent check.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+enum class Status(val value: Int) {
+ /** The site has not been validated yet, pending the background job. */
+ WAITING(1),
+ /** The site is currently being validated. */
+ CHECKING(2),
+ /** The most recent validation attempt passed. */
+ OK(3),
+ /** The site did not pass a recent validation attempt. */
+ ERROR(4);
+
+ companion object {
+ fun fromValue(value: Int) = when (value) {
+ OK.value -> OK
+ WAITING.value -> WAITING
+ CHECKING.value -> CHECKING
+ ERROR.value -> ERROR
+ else -> throw IllegalArgumentException("Unknown status: $value")
+ }
+ }
+}
+
+fun Status.textRes() = when (this) {
+ OK -> string.everything_checks_out
+ WAITING -> string.waiting
+ CHECKING -> string.checking_status
+ else -> 0
+}
+
+fun Status?.isPending() = this == WAITING || this == CHECKING
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt
new file mode 100644
index 0000000..31ddb04
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt
@@ -0,0 +1,54 @@
+/**
+ * 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.model
+
+/**
+ * Represents the validation mode of a [Site] - this is the type of
+ * check that is performed to get the site's current [Status].
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+enum class ValidationMode(val value: Int) {
+ /** The site is running normally if its status code is successful. */
+ STATUS_CODE(1),
+ /** The site is running normally if a piece of text is found in its response body. */
+ TERM_SEARCH(2),
+ /** The site is running normally if a block of given JavaScript executes successfully. */
+ JAVASCRIPT(3);
+
+ fun toIndex() = when (this) {
+ STATUS_CODE -> 0
+ TERM_SEARCH -> 1
+ JAVASCRIPT -> 2
+ }
+
+ companion object {
+
+ fun fromValue(value: Int) = when (value) {
+ STATUS_CODE.value -> STATUS_CODE
+ TERM_SEARCH.value -> TERM_SEARCH
+ JAVASCRIPT.value -> JAVASCRIPT
+ else -> throw IllegalArgumentException("Unknown validationMode: $value")
+ }
+
+ fun fromIndex(index: Int) = when (index) {
+ 0 -> STATUS_CODE
+ 1 -> TERM_SEARCH
+ 2 -> JAVASCRIPT
+ else -> throw IllegalArgumentException("Index out of range: $index")
+ }
+ }
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/ValidationResult.kt b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationResult.kt
new file mode 100644
index 0000000..3b91214
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationResult.kt
@@ -0,0 +1,43 @@
+/**
+ * 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 com.afollestad.nocknock.data.model.Status.OK
+import java.io.Serializable
+
+/**
+ * Represents the most recent validation result for a [Site].
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+@Entity(tableName = "validation_results")
+data class ValidationResult(
+ /** The [Site] that this result belongs to. */
+ @PrimaryKey(autoGenerate = false) var siteId: Long = 0,
+ /** The timestamp in milliseconds at which this attempt was made. */
+ var timestampMs: Long,
+ /** The result of this validation attempt. */
+ var status: Status,
+ /** If the attempt was not successful, why it was not successful. */
+ var reason: String?
+) : Serializable {
+
+ constructor(): this(0, 0, OK, null)
+}
diff --git a/data/src/main/res/values/strings.xml b/data/src/main/res/values/strings.xml
new file mode 100644
index 0000000..09c580b
--- /dev/null
+++ b/data/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+ Everything checks out!
+ Checking status…
+ Waiting…
+
+
diff --git a/dependencies.gradle b/dependencies.gradle
new file mode 100644
index 0000000..e0e2412
--- /dev/null
+++ b/dependencies.gradle
@@ -0,0 +1,58 @@
+ext.versions = [
+ // Project
+ minSdk : 21,
+ compileSdk : 28,
+ buildTools : '28.0.3',
+ publishVersion : '0.8.8',
+ publishVersionCode : 46,
+
+ // Plugins
+ gradlePlugin : '3.4.0',
+ spotlessPlugin : '3.22.0',
+ versionPlugin : '0.21.0',
+ googleServices : '4.2.0',
+ fabricPlugin : '1.+',
+
+ // Misc
+ okHttp : '3.14.1',
+ rhino : '1.7.10',
+
+ // Kotlin
+ kotlin : '1.3.30',
+ coroutines : '1.2.0',
+ koin : '1.0.2',
+
+ // Google/AndroidX
+ 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.8.1',
+ rxkPrefs : '1.2.5',
+ vvalidator : '0.4.1',
+
+ // Debugging
+ timber : '4.7.1',
+ fabric : '2.9.9@aar',
+
+ // Unit testing
+ junit : '4.12',
+ mockito : '2.27.0',
+ mockitoKotlin : '2.1.0',
+ truth : '0.44',
+
+ // UI testing
+ androidxTestRunner : '1.1.1',
+ androidxTest : '1.1.0',
+ archTesting : '2.0.1'
+]
diff --git a/engine/.gitignore b/engine/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/engine/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/engine/build.gradle b/engine/build.gradle
new file mode 100644
index 0000000..1808f4c
--- /dev/null
+++ b/engine/build.gradle
@@ -0,0 +1,37 @@
+apply from: '../dependencies.gradle'
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ compileSdkVersion versions.compileSdk
+
+ defaultConfig {
+ minSdkVersion versions.minSdk
+ targetSdkVersion versions.compileSdk
+ versionCode versions.publishVersionCode
+ versionName versions.publishVersion
+ }
+}
+
+dependencies {
+ implementation project(':common')
+ implementation project(':data')
+ implementation project(':notifications')
+
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
+ api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:' + versions.coroutines
+ api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:' + versions.coroutines
+
+ implementation 'org.koin:koin-android:' + versions.koin
+
+ api 'com.squareup.okhttp3:okhttp:' + versions.okHttp
+
+ implementation 'com.jakewharton.timber:timber:' + versions.timber
+
+ testImplementation 'junit:junit:' + versions.junit
+ testImplementation 'org.mockito:mockito-core:' + versions.mockito
+ testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin
+ testImplementation 'com.google.truth:truth:' + versions.truth
+}
+
+apply from: '../spotless.gradle'
\ No newline at end of file
diff --git a/engine/src/main/AndroidManifest.xml b/engine/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..198c4f6
--- /dev/null
+++ b/engine/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt
new file mode 100644
index 0000000..071960c
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt
@@ -0,0 +1,32 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.engine
+
+import 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 {
+ RealValidationExecutor(get(), get(), get(), get(), get(), get(), get())
+ } bind ValidationExecutor::class
+
+ factory { RealSslManager(get()) } bind SslManager::class
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/ssl/SslManager.kt b/engine/src/main/java/com/afollestad/nocknock/engine/ssl/SslManager.kt
new file mode 100644
index 0000000..aceeb22
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/ssl/SslManager.kt
@@ -0,0 +1,98 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.engine.ssl
+
+import android.app.Application
+import android.content.Context
+import android.net.Uri
+import androidx.annotation.CheckResult
+import com.afollestad.nocknock.utilities.ext.toUri
+import okhttp3.OkHttpClient
+import java.io.BufferedInputStream
+import java.io.FileInputStream
+import java.security.KeyStore
+import java.security.cert.CertificateFactory
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManagerFactory
+import javax.net.ssl.X509TrustManager
+import timber.log.Timber.d as log
+
+/** @author Aidan Follestad (@afollestad) */
+interface SslManager {
+
+ @CheckResult fun clientForCertificate(
+ certUri: String,
+ siteUri: String,
+ client: OkHttpClient
+ ): OkHttpClient
+}
+
+/** @author Aidan Follestad (@afollestad) **/
+class RealSslManager(
+ private val app: Application
+) : SslManager {
+
+ override fun clientForCertificate(
+ certUri: String,
+ siteUri: String,
+ client: OkHttpClient
+ ): OkHttpClient {
+ val parsedCertUri = certUri.toUri()
+ val parsedSiteUri = siteUri.toUri()
+ val siteHost = parsedSiteUri.host ?: ""
+
+ log("Loading certificate $certUri for host $siteHost")
+ val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
+ keyStore.load(null, null)
+
+ val certInputStream = app.openUri(parsedCertUri)
+ val bis = BufferedInputStream(certInputStream)
+ val certificateFactory = CertificateFactory.getInstance("X.509")
+
+ while (bis.available() > 0) {
+ val cert = certificateFactory.generateCertificate(bis)
+ keyStore.setCertificateEntry(siteHost, cert)
+ }
+
+ val trustManagerFactory =
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
+ trustManagerFactory.init(keyStore)
+
+ val trustManagers = trustManagerFactory.trustManagers
+ val sslContext = SSLContext.getInstance("TLS")
+ sslContext.init(null, trustManagers, null)
+
+ val trustManager = trustManagers.first() as X509TrustManager
+ log("Loaded successfully!")
+ return client.newBuilder()
+ .sslSocketFactory(sslContext.socketFactory, trustManager)
+ .hostnameVerifier { hostname, _ ->
+ log("Verifying hostname $hostname")
+ hostname == siteHost
+ }
+ .build()
+ }
+}
+
+private fun Context.openUri(uri: Uri) = when (uri.scheme) {
+ "content" -> {
+ contentResolver.openInputStream(uri) ?: throw IllegalStateException(
+ "Unable to open input stream to $uri"
+ )
+ }
+ "file" -> FileInputStream(uri.path)
+ else -> FileInputStream(uri.toString())
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt
new file mode 100644
index 0000000..cc80509
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt
@@ -0,0 +1,56 @@
+/**
+ * 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.validation
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.ACTION_BOOT_COMPLETED
+import com.afollestad.nocknock.utilities.Qualifiers.IO_DISPATCHER
+import com.afollestad.nocknock.utilities.Qualifiers.MAIN_DISPATCHER
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.koin.standalone.KoinComponent
+import org.koin.standalone.inject
+import timber.log.Timber.d as log
+
+/** @author Aidan Follestad (@afollestad) */
+class BootReceiver : BroadcastReceiver(), KoinComponent {
+
+ private val validationManager by inject()
+ private val mainDispatcher by inject(name = MAIN_DISPATCHER)
+ private val ioDispatcher by inject(name = IO_DISPATCHER)
+
+ override fun onReceive(
+ context: Context,
+ intent: Intent
+ ) {
+ require(ACTION_BOOT_COMPLETED == intent.action) {
+ "BootReceiver should only receive ACTION_BOOT_COMPLETED intents."
+ }
+
+ log("Received boot event! Let's go.")
+
+ val pendingResult = goAsync()
+ GlobalScope.launch(mainDispatcher) {
+ withContext(ioDispatcher) { validationManager.ensureScheduledValidations() }
+ pendingResult.resultCode = 0
+ pendingResult.finish()
+ }
+ }
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt
new file mode 100644
index 0000000..d47d616
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt
@@ -0,0 +1,225 @@
+/**
+ * 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.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) */
+data class CheckResult(
+ val model: Site,
+ val response: Response? = null
+)
+
+typealias ClientTimeoutChanger = (client: OkHttpClient, timeout: Int) -> OkHttpClient
+
+typealias UriConverter = (String) -> Uri
+
+/** @author Aidan Follestad (@afollestad) */
+interface ValidationExecutor {
+
+ suspend fun ensureScheduledValidations()
+
+ fun scheduleValidation(
+ site: Site,
+ rightNow: Boolean = false,
+ cancelPrevious: Boolean = rightNow,
+ fromFinishingJob: Boolean = false,
+ overrideDelay: Long = -1
+ )
+
+ fun cancelScheduledValidation(site: Site)
+
+ suspend fun performValidation(site: Site): CheckResult
+}
+
+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,
+ private val sslManager: SslManager
+) : ValidationExecutor {
+
+ private var clientTimeoutChanger: ClientTimeoutChanger = { client, timeout ->
+ client.newBuilder()
+ .callTimeout(timeout.toLong(), MILLISECONDS)
+ .build()
+ }
+
+ override suspend fun ensureScheduledValidations() {
+ val sites = database.allSites()
+ if (sites.isEmpty()) {
+ return
+ }
+ 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.")
+ scheduleValidation(site = site, rightNow = true)
+ } else {
+ log("Site ${site.id} already has a scheduled job. Nothing to do.")
+ }
+ }
+ }
+
+ override fun scheduleValidation(
+ site: Site,
+ rightNow: Boolean,
+ cancelPrevious: Boolean,
+ fromFinishingJob: Boolean,
+ overrideDelay: Long
+ ) {
+ 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) {
+ cancelScheduledValidation(site)
+ } else if (!fromFinishingJob) {
+ val existingJob = jobForSite(site)
+ check(existingJob == null) {
+ "Site ${site.id} already has a scheduled job, and cancelPrevious = false."
+ }
+ }
+
+ log("Requesting a validation job for site to be scheduled: $site")
+ val extras = bundleProvider.createPersistable {
+ putLong(KEY_SITE_ID, site.id)
+ }
+ val jobInfo = jobInfoProvider.createCheckJob(
+ id = site.id.toInt(),
+ onlyUnmeteredNetwork = false,
+ delayMs = when {
+ rightNow -> 1
+ overrideDelay > -1 -> overrideDelay
+ else -> siteSettings.validationIntervalMs
+ },
+ extras = extras,
+ target = ValidationJob::class.java
+ )
+
+ val dispatchResult = jobScheduler.schedule(jobInfo)
+ if (dispatchResult != RESULT_SUCCESS) {
+ log("Failed to schedule a validation job for site: ${site.id}")
+ } else {
+ log("Validation job successfully scheduled 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 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." }
+ log("performValidation(${site.id}) - GET ${site.url}")
+
+ val request = Request.Builder()
+ .apply {
+ url(site.url)
+ get()
+ site.headers
+ .filter { header -> header.key.isNotNullOrEmpty() }
+ .forEach { header ->
+ addHeader(header.key, header.value)
+ }
+ }
+ .build()
+
+ return try {
+ 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) {
+ log("performValidation(${site.id}) = Successful")
+ CheckResult(
+ model = site.withStatus(status = OK, reason = null),
+ response = response
+ )
+ } else {
+ log("performValidation(${site.id}) = Failure, HTTP code ${response.code()}")
+ CheckResult(
+ model = site.withStatus(
+ status = ERROR,
+ reason = "Response ${response.code()} - ${response.body()?.string() ?: "Unknown"}"
+ ),
+ response = response
+ )
+ }
+ } catch (timeoutEx: SocketTimeoutException) {
+ log("performValidation(${site.id}) = Socket Timeout")
+ CheckResult(
+ model = site.withStatus(
+ status = ERROR,
+ reason = stringProvider.get(R.string.timeout)
+ )
+ )
+ } catch (ex: Exception) {
+ ex.printStackTrace()
+ log("performValidation(${site.id}) = Error: ${ex.message}")
+ CheckResult(model = site.withStatus(status = ERROR, reason = ex.message))
+ }
+ }
+
+ private fun jobForSite(site: Site) =
+ jobScheduler.allPendingJobs
+ .firstOrNull { job -> job.id == site.id.toInt() }
+
+ @TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) {
+ this.clientTimeoutChanger = changer
+ }
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt
new file mode 100644
index 0000000..435ff89
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt
@@ -0,0 +1,242 @@
+/**
+ * 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.validation
+
+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.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
+import com.afollestad.nocknock.data.model.Status.ERROR
+import com.afollestad.nocknock.data.model.Status.OK
+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.data.model.isPending
+import com.afollestad.nocknock.data.updateSite
+import com.afollestad.nocknock.engine.BuildConfig.APPLICATION_ID
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.utilities.js.JavaScript
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.koin.android.ext.android.inject
+import java.lang.System.currentTimeMillis
+import timber.log.Timber.d as log
+
+/**
+ * The job which is sent to the system JobScheduler to perform site validation in the background.
+ *
+ * @author Aidan Follestad (@afollestad)
+ */
+class ValidationJob : JobService() {
+
+ companion object {
+ const val ACTION_STATUS_UPDATE = "$APPLICATION_ID.STATUS_UPDATE"
+ const val ACTION_JOB_RUNNING = "$APPLICATION_ID.STATUS_JOB_RUNNING"
+ const val KEY_UPDATE_MODEL = "site_model"
+ const val KEY_SITE_ID = "site.id"
+ }
+
+ private val database by inject()
+ private val validationManager by inject()
+ private val notificationManager by inject()
+
+ override fun onStartJob(params: JobParameters): Boolean {
+ val siteId = params.extras.getLong(KEY_SITE_ID)
+
+ GlobalScope.launch(Main) {
+ val site = withContext(IO) { database.getSite(siteId) }
+ if (site == null) {
+ log("Unable to find a site for ID $siteId, this job will not be rescheduled.")
+ return@launch jobFinished(params, false)
+ }
+
+ val siteSettings = site.settings
+ requireNotNull(siteSettings) { "Site settings must be populated." }
+
+ log("Performing status checks on site ${site.id}...")
+ 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.performValidation(site)
+ val resultModel = checkResult.model
+ val resultResponse = checkResult.response
+ val result = resultModel.lastResult!!
+
+ if (result.status != OK) {
+ log("Got unsuccessful check status back: ${result.reason}")
+ return@async updateStatus(site = resultModel)
+ } else {
+ when (siteSettings.validationMode) {
+ TERM_SEARCH -> {
+ val body = resultResponse?.body()?.string() ?: ""
+ log("Using TERM_SEARCH validation mode on body of length: ${body.length}")
+
+ return@async if (!body.contains(siteSettings.validationArgs ?: "")) {
+ updateStatus(
+ resultModel.withStatus(
+ status = ERROR,
+ reason = "Term \"${siteSettings.validationArgs}\" not found in response body."
+ )
+ )
+ } else {
+ updateStatus(site = resultModel)
+ }
+ }
+ JAVASCRIPT -> {
+ val body = resultResponse?.body()?.string() ?: ""
+ log("Using JAVASCRIPT validation mode on body of length: ${body.length}")
+ val reason = JavaScript.eval(siteSettings.validationArgs ?: "", body)
+ return@async if (reason != null) {
+ updateStatus(resultModel.withStatus(reason = reason), status = ERROR)
+ } else {
+ resultModel
+ }
+ }
+ STATUS_CODE -> {
+ // We already know the status code is successful because we are in this else branch
+ log("Using STATUS_CODE validation, which has passed!")
+ updateStatus(
+ resultModel.withStatus(
+ status = OK,
+ reason = null
+ )
+ )
+ }
+ else -> {
+ throw IllegalArgumentException(
+ "Unknown validation mode: ${siteSettings.validationArgs}"
+ )
+ }
+ }
+ }
+ }.await()
+
+ 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) {
+ log("Check for site ${site.id} was unsuccessful. BUT we have a retryPolicy.")
+
+ if (retryPolicy.triesLeft == -1 || retryPolicy.triesLeft > 0) {
+ if (retryPolicy.triesLeft == -1) {
+ retryPolicy.triesLeft = retryPolicy.count
+ } else {
+ retryPolicy.triesLeft -= 1
+ }
+ updateTriesLeft(retryPolicy, retryPolicy.triesLeft)
+
+ val interval = retryPolicy.interval()
+ validationManager.scheduleValidation(
+ site = jobResult,
+ fromFinishingJob = true,
+ overrideDelay = interval
+ )
+ log("Scheduling retry in $interval milliseconds.")
+
+ return@launch
+ } else {
+ updateTriesLeft(retryPolicy, -1)
+ log("No tries left, continuing to error notification.")
+ }
+ }
+
+ notificationManager.postValidationErrorNotification(jobResult)
+ }
+
+ validationManager.scheduleValidation(
+ site = jobResult,
+ fromFinishingJob = true
+ )
+ }
+
+ return true
+ }
+
+ override fun onStopJob(params: JobParameters): Boolean {
+ val siteId = params.extras.getLong(KEY_SITE_ID)
+ log("Check job for site $siteId is done")
+ return true
+ }
+
+ private suspend fun updateStatus(
+ site: Site,
+ status: Status = site.lastResult?.status ?: WAITING
+ ): Site {
+ log("Updating ${site.name} (${site.url}) status to $status...")
+
+ val lastCheckTime =
+ if (status.isPending()) site.lastResult?.timestampMs ?: -1
+ else currentTimeMillis()
+ val reason =
+ if (status == OK) null
+ else site.lastResult?.reason ?: "Unknown"
+
+ if (site.retryPolicy != null && status == OK) {
+ site.retryPolicy = site.retryPolicy!!.copy(
+ triesLeft = -1,
+ lastTryTimestamp = 0
+ )
+ }
+
+ val updatedModel = site.withStatus(
+ status = status,
+ timestamp = lastCheckTime,
+ reason = reason
+ )
+ database.updateSite(updatedModel)
+
+ withContext(Main) {
+ sendBroadcast(Intent(ACTION_STATUS_UPDATE).apply {
+ putExtra(KEY_UPDATE_MODEL, updatedModel)
+ })
+ }
+ return updatedModel
+ }
+
+ private suspend fun updateTriesLeft(
+ retryPolicy: RetryPolicy,
+ triesLeft: Int
+ ) {
+ retryPolicy.triesLeft = triesLeft
+ retryPolicy.lastTryTimestamp = currentTimeMillis()
+ withContext(IO) {
+ database.retryPolicyDao()
+ .update(retryPolicy)
+ }
+ log("Tries left for site ${retryPolicy.siteId}: $triesLeft")
+ }
+}
diff --git a/engine/src/main/res/values/strings.xml b/engine/src/main/res/values/strings.xml
new file mode 100644
index 0000000..d899d07
--- /dev/null
+++ b/engine/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Nock Nock Status Service
+ Request timed out! Your server is probably down.
+
+
diff --git a/engine/src/test/java/com/afollestad/nocknock/engine/TestData.kt b/engine/src/test/java/com/afollestad/nocknock/engine/TestData.kt
new file mode 100644
index 0000000..cfe4a56
--- /dev/null
+++ b/engine/src/test/java/com/afollestad/nocknock/engine/TestData.kt
@@ -0,0 +1,189 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.engine
+
+import android.content.Intent
+import com.afollestad.nocknock.data.AppDatabase
+import com.afollestad.nocknock.data.HeaderDao
+import com.afollestad.nocknock.data.RetryPolicyDao
+import com.afollestad.nocknock.data.SiteDao
+import com.afollestad.nocknock.data.SiteSettingsDao
+import com.afollestad.nocknock.data.ValidationResultsDao
+import com.afollestad.nocknock.data.model.Header
+import com.afollestad.nocknock.data.model.RetryPolicy
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.SiteSettings
+import com.afollestad.nocknock.data.model.Status
+import com.afollestad.nocknock.data.model.Status.OK
+import com.afollestad.nocknock.data.model.ValidationMode
+import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
+import com.afollestad.nocknock.data.model.ValidationResult
+import com.nhaarman.mockitokotlin2.doAnswer
+import com.nhaarman.mockitokotlin2.doReturn
+import com.nhaarman.mockitokotlin2.isA
+import com.nhaarman.mockitokotlin2.mock
+import java.lang.System.currentTimeMillis
+
+fun fakeIntent(action: String): Intent {
+ return mock {
+ on { getAction() } doReturn action
+ }
+}
+
+fun fakeSettingsModel(
+ id: Long,
+ validationMode: ValidationMode = STATUS_CODE
+) = SiteSettings(
+ siteId = id,
+ validationIntervalMs = 600000,
+ validationMode = validationMode,
+ validationArgs = null,
+ disabled = false,
+ networkTimeout = 10000,
+ certificate = null
+)
+
+fun fakeResultModel(
+ id: Long,
+ status: Status = OK,
+ reason: String? = null
+) = ValidationResult(
+ siteId = id,
+ status = status,
+ reason = reason,
+ timestampMs = currentTimeMillis()
+)
+
+fun fakeRetryPolicy(
+ id: Long,
+ count: Int = 3,
+ minutes: Int = 6
+) = RetryPolicy(
+ siteId = id,
+ count = count,
+ minutes = minutes
+)
+
+fun fakeHeaders(siteId: Long): List {
+ return listOf(
+ Header(id = siteId + 1, siteId = siteId, key = "Content-Type", value = "text/html"),
+ Header(id = siteId + 2, siteId = siteId, key = "User-Agent", value = "NockNock")
+ )
+}
+
+fun fakeModel(id: Long) = Site(
+ id = id,
+ name = "Test",
+ url = "https://test.com",
+ tags = "",
+ settings = fakeSettingsModel(id),
+ lastResult = fakeResultModel(id),
+ retryPolicy = fakeRetryPolicy(id),
+ headers = fakeHeaders(id)
+)
+
+val MOCK_MODEL_1 = fakeModel(1)
+val MOCK_MODEL_2 = fakeModel(2)
+val MOCK_MODEL_3 = fakeModel(3)
+val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3)
+
+fun mockDatabase(): AppDatabase {
+ val siteDao = mock {
+ on { insert(isA()) } doReturn 1
+ on { one(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> listOf(MOCK_MODEL_1)
+ 2L -> listOf(MOCK_MODEL_2)
+ 3L -> listOf(MOCK_MODEL_3)
+ else -> listOf()
+ }
+ }
+ on { all() } doReturn ALL_MOCK_MODELS
+ on { update(isA()) } doAnswer { inv ->
+ return@doAnswer inv.arguments.size
+ }
+ on { delete(isA()) } doAnswer { inv ->
+ return@doAnswer inv.arguments.size
+ }
+ }
+ val settingsDao = mock {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> listOf(MOCK_MODEL_1.settings!!)
+ 2L -> listOf(MOCK_MODEL_2.settings!!)
+ 3L -> listOf(MOCK_MODEL_3.settings!!)
+ else -> listOf()
+ }
+ }
+ on { update(isA()) } doReturn 1
+ on { delete(isA()) } doReturn 1
+ }
+ val resultsDao = mock {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> listOf(MOCK_MODEL_1.lastResult!!)
+ 2L -> listOf(MOCK_MODEL_2.lastResult!!)
+ 3L -> listOf(MOCK_MODEL_3.lastResult!!)
+ else -> listOf()
+ }
+ }
+ on { update(isA()) } doReturn 1
+ on { delete(isA()) } doReturn 1
+ }
+ val retryDao = mock {
+ on { insert(isA()) } doReturn 1L
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> listOf(MOCK_MODEL_1.retryPolicy!!)
+ 2L -> listOf(MOCK_MODEL_2.retryPolicy!!)
+ 3L -> listOf(MOCK_MODEL_3.retryPolicy!!)
+ else -> listOf()
+ }
+ }
+ on { update(isA()) } doReturn 1
+ on { delete(isA()) } doReturn 1
+ }
+ val headerDao = mock {
+ on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
+ on { forSite(isA()) } doAnswer { inv ->
+ val id = inv.getArgument(0)
+ return@doAnswer when (id) {
+ 1L -> MOCK_MODEL_1.headers
+ 2L -> MOCK_MODEL_2.headers
+ 3L -> MOCK_MODEL_3.headers
+ else -> listOf()
+ }
+ }
+ on { insert(isA()) } doReturn 1L
+ on { insert(isA>()) } doReturn listOf(1L, 2L)
+ on { update(isA()) } doReturn 1
+ on { delete(isA()) } doReturn 1
+ }
+
+ return mock {
+ on { siteDao() } doReturn siteDao
+ on { siteSettingsDao() } doReturn settingsDao
+ on { validationResultsDao() } doReturn resultsDao
+ on { retryPolicyDao() } doReturn retryDao
+ on { headerDao() } doReturn headerDao
+ }
+}
diff --git a/engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt b/engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt
new file mode 100644
index 0000000..7255e9f
--- /dev/null
+++ b/engine/src/test/java/com/afollestad/nocknock/engine/TestUtil.kt
@@ -0,0 +1,88 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.engine
+
+import android.app.job.JobInfo
+import android.content.ComponentName
+import android.os.PersistableBundle
+import com.afollestad.nocknock.data.AppDatabase
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.utilities.providers.BundleProvider
+import com.afollestad.nocknock.utilities.providers.IBundle
+import com.afollestad.nocknock.utilities.providers.IBundler
+import com.afollestad.nocknock.utilities.providers.JobInfoProvider
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.doAnswer
+import com.nhaarman.mockitokotlin2.doReturn
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.whenever
+
+fun testBundleProvider(): BundleProvider {
+ val provider = mock()
+ whenever(provider.createPersistable(any())).doAnswer {
+ val realBundle = mock()
+ val creator = it.getArgument(0)
+ creator(object : IBundle {
+ override fun putLong(
+ key: String,
+ value: Long
+ ) {
+ whenever(realBundle.getLong(key)).doReturn(value)
+ }
+ })
+ return@doAnswer realBundle
+ }
+ return provider
+}
+
+fun testJobInfoProvider(): JobInfoProvider {
+ val provider = mock()
+ whenever(provider.createCheckJob(any(), any(), any(), any(), any())).doAnswer { inv ->
+ val jobInfo = mock()
+ val id = inv.getArgument(0)
+ val delay = inv.getArgument(2)
+ val extras = inv.getArgument(3)
+ val target = inv.getArgument>(4)
+ val component = mock()
+ whenever(component.className).doReturn(target.name)
+
+ whenever(jobInfo.id).doReturn(id)
+ whenever(jobInfo.minLatencyMillis).doReturn(delay)
+ whenever(jobInfo.extras).doReturn(extras)
+ whenever(jobInfo.service).doReturn(component)
+
+ return@doAnswer jobInfo
+ }
+ return provider
+}
+
+fun AppDatabase.setAllSites(vararg sites: Site) {
+ whenever(siteDao().all()).doReturn(listOf(*sites))
+ for (site in sites) {
+ whenever(siteSettingsDao().forSite(site.id))
+ .doReturn(listOf(site.settings!!))
+ if (site.lastResult != null) {
+ whenever(validationResultsDao().forSite(site.id))
+ .doReturn(listOf(site.lastResult!!))
+ }
+ if (site.retryPolicy != null) {
+ whenever(retryPolicyDao().forSite(site.id))
+ .doReturn(listOf(site.retryPolicy!!))
+ }
+ whenever(headerDao().forSite(site.id))
+ .doReturn(site.headers)
+ }
+}
diff --git a/engine/src/test/java/com/afollestad/nocknock/engine/ValidationExecutorTest.kt b/engine/src/test/java/com/afollestad/nocknock/engine/ValidationExecutorTest.kt
new file mode 100644
index 0000000..dff3217
--- /dev/null
+++ b/engine/src/test/java/com/afollestad/nocknock/engine/ValidationExecutorTest.kt
@@ -0,0 +1,345 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.engine
+
+import android.app.job.JobInfo
+import android.app.job.JobScheduler
+import com.afollestad.nocknock.data.model.Header
+import com.afollestad.nocknock.data.model.Status.ERROR
+import com.afollestad.nocknock.data.model.Status.OK
+import com.afollestad.nocknock.engine.ssl.SslManager
+import com.afollestad.nocknock.engine.validation.RealValidationExecutor
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID
+import com.afollestad.nocknock.utilities.providers.StringProvider
+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.verifyNoMoreInteractions
+import com.nhaarman.mockitokotlin2.whenever
+import kotlinx.coroutines.runBlocking
+import okhttp3.Call
+import okhttp3.OkHttpClient
+import okhttp3.Protocol.HTTP_2
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody
+import org.junit.Test
+import java.net.SocketTimeoutException
+
+class ValidationExecutorTest {
+
+ private val timeoutError = "Oh no, a timeout"
+
+ private val jobScheduler = mock()
+ private val okHttpClient = mock()
+ private val stringProvider = mock {
+ on { get(R.string.timeout) } doReturn timeoutError
+ }
+ private val bundleProvider = testBundleProvider()
+ private val jobInfoProvider = testJobInfoProvider()
+ private val database = mockDatabase()
+ private val sslManager = mock {
+ on { clientForCertificate(any(), any(), any()) } doAnswer { inv ->
+ inv.getArgument(2)
+ }
+ }
+
+ private val manager = RealValidationExecutor(
+ jobScheduler,
+ okHttpClient,
+ stringProvider,
+ bundleProvider,
+ jobInfoProvider,
+ database,
+ sslManager
+ ).apply {
+ setClientTimeoutChanger { _, timeout ->
+ whenever(okHttpClient.callTimeoutMillis()).doReturn(timeout)
+ return@setClientTimeoutChanger okHttpClient
+ }
+ }
+
+ @Test fun ensureScheduledValidations_noEnabledSites() = runBlocking {
+ val model1 = fakeModel(id = 1)
+ model1.settings = model1.settings!!.copy(disabled = true)
+ database.setAllSites(model1)
+
+ manager.ensureScheduledValidations()
+
+ verifyNoMoreInteractions(jobScheduler)
+ }
+
+ @Test fun ensureScheduledValidations_sitesAlreadyHaveJobs() = runBlocking {
+ val model1 = fakeModel(id = 1)
+ val job1 = fakeJob(1)
+ database.setAllSites(model1)
+ whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
+
+ manager.ensureScheduledValidations()
+
+ verify(jobScheduler, never()).schedule(any())
+ }
+
+ @Test fun ensureScheduledValidations() = runBlocking {
+ val model1 = fakeModel(id = 1)
+ database.setAllSites(model1)
+
+ whenever(jobScheduler.allPendingJobs).doReturn(listOf())
+
+ manager.ensureScheduledValidations()
+
+ val jobCaptor = argumentCaptor()
+ verify(jobScheduler).schedule(jobCaptor.capture())
+ val jobInfo = jobCaptor.allValues.single()
+ assertThat(jobInfo.id).isEqualTo(model1.id)
+ assertThat(jobInfo.extras.getLong(KEY_SITE_ID)).isEqualTo(model1.id)
+ }
+
+ @Test fun scheduleValidation_rightNow() {
+ val model1 = fakeModel(id = 1)
+ whenever(jobScheduler.allPendingJobs).doReturn(listOf())
+
+ manager.scheduleValidation(
+ site = model1,
+ rightNow = true
+ )
+
+ val jobCaptor = argumentCaptor()
+ verify(jobScheduler).schedule(jobCaptor.capture())
+ verify(jobScheduler).cancel(1)
+
+ val jobInfo = jobCaptor.allValues.single()
+ assertThat(jobInfo.id).isEqualTo(model1.id)
+ assertThat(jobInfo.extras.getLong(KEY_SITE_ID)).isEqualTo(model1.id)
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun scheduleValidation_notFromFinishingJob_haveExistingJob() {
+ val model1 = fakeModel(id = 1)
+ val job1 = fakeJob(1)
+ whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
+
+ manager.scheduleValidation(
+ site = model1,
+ fromFinishingJob = false
+ )
+ }
+
+ @Test fun scheduleValidation_fromFinishingJob_haveExistingJob() {
+ val model1 = fakeModel(id = 1)
+ val job1 = fakeJob(1)
+ whenever(jobScheduler.allPendingJobs).doReturn(listOf(job1))
+
+ manager.scheduleValidation(
+ site = model1,
+ fromFinishingJob = true
+ )
+
+ val jobCaptor = argumentCaptor()
+ verify(jobScheduler).schedule(jobCaptor.capture())
+ verify(jobScheduler, never()).cancel(any())
+
+ val jobInfo = jobCaptor.allValues.single()
+ assertThat(jobInfo.id).isEqualTo(model1.id)
+ assertThat(jobInfo.extras.getLong(KEY_SITE_ID)).isEqualTo(model1.id)
+ }
+
+ @Test fun scheduleValidation() {
+ val model1 = fakeModel(id = 1)
+ whenever(jobScheduler.allPendingJobs).doReturn(listOf())
+
+ manager.scheduleValidation(
+ site = model1,
+ fromFinishingJob = true
+ )
+
+ val jobCaptor = argumentCaptor()
+ verify(jobScheduler).schedule(jobCaptor.capture())
+ verify(jobScheduler, never()).cancel(any())
+
+ val jobInfo = jobCaptor.allValues.single()
+ assertThat(jobInfo.id).isEqualTo(model1.id)
+ assertThat(jobInfo.extras.getLong(KEY_SITE_ID)).isEqualTo(model1.id)
+ }
+
+ @Test fun cancelScheduledValidation() {
+ val model1 = fakeModel(id = 1)
+ manager.cancelScheduledValidation(model1)
+ verify(jobScheduler).cancel(1)
+ }
+
+ @Test fun performValidation_httpNotSuccess() = runBlocking {
+ val response = fakeResponse(500, "Internal Server Error", "Hello World")
+ val call = mock {
+ on { execute() } doReturn response
+ }
+ whenever(okHttpClient.newCall(any())).doReturn(call)
+
+ val model1 = fakeModel(id = 1)
+ val result = manager.performValidation(model1)
+
+ assertThat(result.model).isEqualTo(
+ model1.copy(
+ lastResult = model1.lastResult?.copy(
+ status = ERROR,
+ reason = "Response 500 - Hello World"
+ )
+ )
+ )
+ }
+
+ @Test fun performValidation_socketTimeout() = runBlocking {
+ val error = SocketTimeoutException("Oh no!")
+ val call = mock {
+ on { execute() } doAnswer { throw error }
+ }
+ whenever(okHttpClient.newCall(any())).doReturn(call)
+
+ val model1 = fakeModel(id = 1)
+ val result = manager.performValidation(model1)
+
+ assertThat(result.model).isEqualTo(
+ model1.copy(
+ lastResult = model1.lastResult?.copy(
+ status = ERROR,
+ reason = timeoutError
+ )
+ )
+ )
+ }
+
+ @Test fun performValidation_exception() = runBlocking {
+ val error = Exception("Oh no!")
+ val call = mock {
+ on { execute() } doAnswer { throw error }
+ }
+ whenever(okHttpClient.newCall(any())).doReturn(call)
+
+ val model1 = fakeModel(id = 1)
+ val result = manager.performValidation(model1)
+
+ assertThat(result.model).isEqualTo(
+ model1.copy(
+ lastResult = model1.lastResult?.copy(
+ status = ERROR,
+ reason = "Oh no!"
+ )
+ )
+ )
+ }
+
+ @Test fun performValidation_success_withHeaders() = runBlocking {
+ val requestCaptor = argumentCaptor()
+ val response = fakeResponse(200, "OK", "Hello World")
+
+ val call = mock {
+ on { execute() } doReturn response
+ }
+ whenever(okHttpClient.newCall(requestCaptor.capture()))
+ .doReturn(call)
+
+ val model1 = fakeModel(id = 1).copy(
+ headers = listOf(
+ Header(
+ key = "X-Test-Header",
+ value = "Hello, World!"
+ )
+ )
+ )
+ val result = manager.performValidation(model1)
+ val httpRequest = requestCaptor.firstValue
+
+ assertThat(result.model).isEqualTo(
+ model1.copy(
+ lastResult = model1.lastResult?.copy(
+ status = OK,
+ reason = null
+ )
+ )
+ )
+ assertThat(okHttpClient.callTimeoutMillis())
+ .isEqualTo(model1.settings!!.networkTimeout)
+ assertThat(httpRequest.header("X-Test-Header"))
+ .isEqualTo("Hello, World!")
+ }
+
+ @Test fun performValidation_success_withCustomSslCert() = runBlocking {
+ val response = fakeResponse(200, "OK", "Hello World")
+ val call = mock {
+ on { execute() } doReturn response
+ }
+ whenever(okHttpClient.newCall(any())).doReturn(call)
+
+ val model1 = fakeModel(id = 1).copy(
+ url = "http://wwww.mysite.com/test.html",
+ headers = emptyList()
+ )
+ model1.settings = model1.settings!!.copy(
+ certificate = "file:///sdcard/cert.pem"
+ )
+ val result = manager.performValidation(model1)
+
+ assertThat(result.model).isEqualTo(
+ model1.copy(
+ lastResult = model1.lastResult?.copy(
+ status = OK,
+ reason = null
+ )
+ )
+ )
+ assertThat(okHttpClient.callTimeoutMillis())
+ .isEqualTo(model1.settings!!.networkTimeout)
+
+ verify(sslManager).clientForCertificate(
+ "file:///sdcard/cert.pem",
+ "http://wwww.mysite.com/test.html",
+ okHttpClient
+ )
+ }
+
+ private fun fakeResponse(
+ code: Int,
+ message: String,
+ body: String?
+ ): Response {
+ val responseBody = if (body != null) {
+ ResponseBody.create(null, body)
+ } else {
+ null
+ }
+ val request = Request.Builder()
+ .url("https://placeholder.com")
+ .build()
+ return Response.Builder()
+ .protocol(HTTP_2)
+ .request(request)
+ .message(message)
+ .code(code)
+ .body(responseBody)
+ .build()
+ }
+
+ private fun fakeJob(id: Int): JobInfo {
+ return mock {
+ on { this.id } doReturn id
+ }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 9516cf5..c62d03d 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -16,3 +16,6 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryErro
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
+
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index cf2d737..2128899 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
-#Wed Aug 24 19:51:45 CDT 2016
+#Fri Jan 05 14:44:01 CST 2018
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
diff --git a/ic_web.png b/ic_web.png
index 509177f..d54b6ec 100644
Binary files a/ic_web.png and b/ic_web.png differ
diff --git a/mock/mock-google-services.json b/mock/mock-google-services.json
new file mode 100644
index 0000000..d9866d8
--- /dev/null
+++ b/mock/mock-google-services.json
@@ -0,0 +1,42 @@
+{
+ "project_info": {
+ "project_number": "123456789000",
+ "firebase_url": "https://mockproject-1234.firebaseio.com",
+ "project_id": "mockproject-1234",
+ "storage_bucket": "mockproject-1234.appspot.com"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063",
+ "android_client_info": {
+ "package_name": "com.afollestad.nocknock"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "123456789000-hjugbg6ud799v4c49dim8ce2usclthar.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "AIzbSzCn1N6LWIe6wthYyrgUUSAlUsdqMb-wvTo"
+ }
+ ],
+ "services": {
+ "analytics_service": {
+ "status": 1
+ },
+ "appinvite_service": {
+ "status": 1,
+ "other_platform_oauth_client": []
+ },
+ "ads_service": {
+ "status": 2
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
diff --git a/mock/mock.gradle b/mock/mock.gradle
new file mode 100644
index 0000000..2fa58ef
--- /dev/null
+++ b/mock/mock.gradle
@@ -0,0 +1,13 @@
+// This script must be applied in app/build.gradle for the paths here to work correctly
+
+def copyMockFilesNeeded() {
+ def srcGoogleServicesFile = file("../mock/mock-google-services.json")
+ def destGoogleServicesFile = file("google-services.json")
+ if (!destGoogleServicesFile.exists()) {
+ destGoogleServicesFile.write(srcGoogleServicesFile.text)
+ }
+}
+
+afterEvaluate {
+ copyMockFilesNeeded()
+}
\ No newline at end of file
diff --git a/notifications/.gitignore b/notifications/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/notifications/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/notifications/build.gradle b/notifications/build.gradle
new file mode 100644
index 0000000..5cfdc84
--- /dev/null
+++ b/notifications/build.gradle
@@ -0,0 +1,35 @@
+apply from: '../dependencies.gradle'
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ compileSdkVersion versions.compileSdk
+
+ defaultConfig {
+ minSdkVersion versions.minSdk
+ targetSdkVersion versions.compileSdk
+ versionCode versions.publishVersionCode
+ versionName versions.publishVersion
+ }
+}
+
+dependencies {
+ implementation project(':common')
+
+ api 'androidx.appcompat:appcompat:' + versions.androidxCore
+
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
+ api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:' + versions.coroutines
+ api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:' + versions.coroutines
+
+ implementation 'org.koin:koin-android:' + versions.koin
+
+ implementation 'com.jakewharton.timber:timber:' + versions.timber
+
+ testImplementation 'junit:junit:' + versions.junit
+ testImplementation 'org.mockito:mockito-core:' + versions.mockito
+ testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin
+ testImplementation 'com.google.truth:truth:' + versions.truth
+}
+
+apply from: '../spotless.gradle'
\ No newline at end of file
diff --git a/notifications/src/main/AndroidManifest.xml b/notifications/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..34182e8
--- /dev/null
+++ b/notifications/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/Channel.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/Channel.kt
new file mode 100644
index 0000000..7cad6ca
--- /dev/null
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/Channel.kt
@@ -0,0 +1,39 @@
+/**
+ * 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.notifications
+
+import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT
+
+/** @author Aidan Follestad (@afollestad) */
+enum class Channel(
+ val id: String,
+ val title: Int,
+ val description: Int,
+ val importance: Int
+) {
+ ValidationSuccess(
+ id = "check_success",
+ title = R.string.channel_server_check_success_title,
+ description = R.string.channel_server_check_success_description,
+ importance = IMPORTANCE_DEFAULT
+ ),
+ ValidationErrors(
+ id = "check_failures",
+ title = R.string.channel_server_check_failures_title,
+ description = R.string.channel_server_check_failures_description,
+ importance = IMPORTANCE_DEFAULT
+ )
+}
diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt
new file mode 100644
index 0000000..84addbc
--- /dev/null
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt
@@ -0,0 +1,129 @@
+/**
+ * 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.notifications
+
+import android.annotation.TargetApi
+import android.app.NotificationManager
+import android.os.Build.VERSION_CODES
+import com.afollestad.nocknock.notifications.Channel.ValidationErrors
+import com.afollestad.nocknock.utilities.providers.CanNotifyModel
+import com.afollestad.nocknock.utilities.providers.IntentProvider
+import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider
+import com.afollestad.nocknock.utilities.providers.NotificationProvider
+import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.BASE_NOTIFICATION_REQUEST_CODE
+import com.afollestad.nocknock.utilities.providers.StringProvider
+import timber.log.Timber.d as log
+
+/** @author Aidan Follestad (@afollestad) */
+interface NockNotificationManager {
+
+ fun setIsAppOpen(open: Boolean)
+
+ fun createChannels()
+
+ fun postValidationErrorNotification(model: CanNotifyModel)
+
+ fun postValidationSuccessNotification(model: CanNotifyModel)
+
+ fun cancelStatusNotification(model: CanNotifyModel)
+
+ fun cancelStatusNotifications()
+}
+
+/** @author Aidan Follestad (@afollestad) */
+class RealNockNotificationManager(
+ private val stockManager: NotificationManager,
+ private val stringProvider: StringProvider,
+ private val intentProvider: IntentProvider,
+ private val channelProvider: NotificationChannelProvider,
+ private val notificationProvider: NotificationProvider
+) : NockNotificationManager {
+
+ private var isAppOpen = false
+
+ override fun setIsAppOpen(open: Boolean) {
+ this.isAppOpen = open
+ log("Is app open? $open")
+ }
+
+ override fun createChannels() =
+ Channel.values().forEach(this::createChannel)
+
+ override fun postValidationErrorNotification(model: CanNotifyModel) {
+ if (isAppOpen) {
+ // Don't show notifications while the app is open
+ log("App is open, validation error notification for site ${model.notifyId()} won't be posted.")
+ return
+ }
+
+ log("Posting validation error notification for site ${model.notifyId()}...")
+ val intent = intentProvider.getPendingIntentForViewSite(model)
+
+ val newNotification = notificationProvider.create(
+ channelId = ValidationErrors.id,
+ title = model.notifyName(),
+ content = model.notifyDescription() ?: stringProvider.get(R.string.something_wrong),
+ intent = intent,
+ smallIcon = R.drawable.ic_notification_error
+ )
+
+ stockManager.notify(model.notifyTag(), model.notificationId(), newNotification)
+ log("Posted validation error notification for site ${model.notificationId()}.")
+ }
+
+ override fun postValidationSuccessNotification(model: CanNotifyModel) {
+ if (isAppOpen) {
+ // Don't show notifications while the app is open
+ log("App is open, validation success notification for site ${model.notifyId()} won't be posted.")
+ return
+ }
+
+ log("Posting validation success notification for site ${model.notifyId()}...")
+ val intent = intentProvider.getPendingIntentForViewSite(model)
+
+ val newNotification = notificationProvider.create(
+ channelId = ValidationErrors.id,
+ title = model.notifyName(),
+ content = stringProvider.get(R.string.validation_passed),
+ intent = intent,
+ smallIcon = R.drawable.ic_notification_success
+ )
+
+ stockManager.notify(model.notifyTag(), model.notificationId(), newNotification)
+ log("Posted validation success notification for site ${model.notificationId()}.")
+ }
+
+ override fun cancelStatusNotification(model: CanNotifyModel) {
+ stockManager.cancel(model.notificationId())
+ log("Cancelled status notification for site ${model.notifyId()}.")
+ }
+
+ override fun cancelStatusNotifications() = stockManager.cancelAll()
+
+ @TargetApi(VERSION_CODES.O)
+ private fun createChannel(channel: Channel) {
+ val notificationChannel = channelProvider.create(
+ id = channel.id,
+ title = stringProvider.get(channel.title),
+ description = stringProvider.get(channel.description),
+ importance = channel.importance
+ )
+ notificationChannel?.let(stockManager::createNotificationChannel)
+ log("Created notification channel ${channel.id}")
+ }
+
+ private fun CanNotifyModel.notificationId() = BASE_NOTIFICATION_REQUEST_CODE + this.notifyId()
+}
diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.kt
new file mode 100644
index 0000000..73274c5
--- /dev/null
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.kt
@@ -0,0 +1,64 @@
+/**
+ * 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.notifications
+
+import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS
+import com.afollestad.nocknock.utilities.providers.BundleProvider
+import com.afollestad.nocknock.utilities.providers.IntentProvider
+import com.afollestad.nocknock.utilities.providers.JobInfoProvider
+import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider
+import com.afollestad.nocknock.utilities.providers.NotificationProvider
+import com.afollestad.nocknock.utilities.providers.RealBundleProvider
+import com.afollestad.nocknock.utilities.providers.RealIntentProvider
+import com.afollestad.nocknock.utilities.providers.RealJobInfoProvider
+import com.afollestad.nocknock.utilities.providers.RealNotificationChannelProvider
+import com.afollestad.nocknock.utilities.providers.RealNotificationProvider
+import com.afollestad.nocknock.utilities.providers.RealSdkProvider
+import com.afollestad.nocknock.utilities.providers.SdkProvider
+import org.koin.dsl.module.module
+
+object Qualifiers {
+ const val MAIN_ACTIVITY_CLASS = "main_activity_class"
+}
+
+val notificationsModule = module {
+
+ factory {
+ RealIntentProvider(get(), get(name = MAIN_ACTIVITY_CLASS))
+ } bind IntentProvider::class
+
+ factory { RealSdkProvider() } bind SdkProvider::class
+
+ factory {
+ RealNotificationChannelProvider(get())
+ } bind NotificationChannelProvider::class
+
+ factory { RealNotificationProvider(get()) } bind NotificationProvider::class
+
+ factory { RealBundleProvider() } bind BundleProvider::class
+
+ factory { RealJobInfoProvider(get()) } bind JobInfoProvider::class
+
+ single {
+ RealNockNotificationManager(
+ get(),
+ get(),
+ get(),
+ get(),
+ get()
+ )
+ } bind NockNotificationManager::class
+}
diff --git a/notifications/src/main/res/drawable/ic_notification_error.xml b/notifications/src/main/res/drawable/ic_notification_error.xml
new file mode 100644
index 0000000..df55b3c
--- /dev/null
+++ b/notifications/src/main/res/drawable/ic_notification_error.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/notifications/src/main/res/drawable/ic_notification_success.xml b/notifications/src/main/res/drawable/ic_notification_success.xml
new file mode 100644
index 0000000..290fb76
--- /dev/null
+++ b/notifications/src/main/res/drawable/ic_notification_success.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/notifications/src/main/res/values/strings.xml b/notifications/src/main/res/values/strings.xml
new file mode 100644
index 0000000..4462309
--- /dev/null
+++ b/notifications/src/main/res/values/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Site Validation Failures
+
+ Notifications for Nock Nock validations failing for your sites. Something has gone
+ wrong if you see one of these.
+
+
+ Site Validation Success
+
+ Notifications for Nock Nock when a site validation passes when it previously had not.
+
+
+ Something\'s wrong! Tap for details.
+ Yay! No longer in trouble! Validation passed.
+
+
diff --git a/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt b/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt
new file mode 100644
index 0000000..d4709ab
--- /dev/null
+++ b/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt
@@ -0,0 +1,175 @@
+/**
+ * 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.notifications
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import com.afollestad.nocknock.notifications.Channel.ValidationErrors
+import com.afollestad.nocknock.notifications.Channel.ValidationSuccess
+import com.afollestad.nocknock.utilities.providers.CanNotifyModel
+import com.afollestad.nocknock.utilities.providers.IntentProvider
+import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider
+import com.afollestad.nocknock.utilities.providers.NotificationProvider
+import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.BASE_NOTIFICATION_REQUEST_CODE
+import com.afollestad.nocknock.utilities.providers.StringProvider
+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.times
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Before
+import org.junit.Test
+
+class NockNotificationManagerTest {
+
+ private val somethingWentWrong = "something went wrong"
+ private val yay = "yay!"
+
+ private val stockManager = mock()
+ private val stringProvider = mock {
+ on { get(R.string.something_wrong) } doReturn somethingWentWrong
+ on { get(R.string.validation_passed) } doReturn yay
+ }
+ private val intentProvider = mock()
+ private val channelProvider = mock()
+ private val notificationProvider = mock()
+
+ private val manager = RealNockNotificationManager(
+ stockManager,
+ stringProvider,
+ intentProvider,
+ channelProvider,
+ notificationProvider
+ )
+
+ @Before fun setup() {
+ whenever(channelProvider.create(any(), any(), any(), any())).doAnswer { inv ->
+ val id = inv.getArgument(0)
+ val title = inv.getArgument(1)
+ val description = inv.getArgument(2)
+ val important = inv.getArgument(3)
+ return@doAnswer mock {
+ on { this.id } doReturn id
+ on { this.name } doReturn title
+ on { this.description } doReturn description
+ on { this.importance } doReturn important
+ }
+ }
+ }
+
+ @Test fun createChannels() {
+ whenever(stringProvider.get(any())).doReturn("")
+ val errorChannel = mock