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/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index ecd817f..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 3b31283..0000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/markdown-navigator.xml b/.idea/markdown-navigator.xml
deleted file mode 100644
index d819570..0000000
--- a/.idea/markdown-navigator.xml
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/markdown-navigator/profiles_settings.xml b/.idea/markdown-navigator/profiles_settings.xml
deleted file mode 100644
index 57927c5..0000000
--- a/.idea/markdown-navigator/profiles_settings.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index fbb6828..06ee295 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,46 +1,38 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
\ No newline at end of file
+
diff --git a/.idea/modules.xml b/.idea/modules.xml
index afe79ae..22d4f44 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -2,11 +2,12 @@
-
-
+
+
-
+
+
-
\ No newline at end of file
+
diff --git a/.travis.yml b/.travis.yml
index 9cd1ea0..c1eda38 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,8 +4,8 @@ android:
components:
- tools
- platform-tools
- - build-tools-27.0.2
- - android-27
+ - build-tools-28.0.3
+ - android-28
- extra-android-support
- extra-android-m2repository
- extra-google-m2repository
@@ -19,4 +19,4 @@ android:
#- sys-img-x86-android-17
licenses:
- - '.+'
\ No newline at end of file
+ - '.+'
diff --git a/NockNock-0.1.3.1.apk b/NockNock-0.1.3.1.apk
deleted file mode 100644
index 7eb45b4..0000000
Binary files a/NockNock-0.1.3.1.apk and /dev/null differ
diff --git a/app/build.gradle b/app/build.gradle
index db9afa2..205652d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,40 +1,38 @@
+apply from: '../dependencies.gradle'
apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
+apply plugin: 'kotlin-android-extensions'
android {
- compileSdkVersion versions.compileSdk
- buildToolsVersion versions.buildTools
+ compileSdkVersion versions.compileSdk
+ buildToolsVersion versions.buildTools
- defaultConfig {
- applicationId "com.afollestad.nocknock"
- minSdkVersion versions.minSdk
- targetSdkVersion versions.compileSdk
- versionCode versions.publishVersionCode
- versionName versions.publishVersion
-
- lintOptions {
- abortOnError false
- }
- }
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
- }
- }
+ defaultConfig {
+ applicationId "com.afollestad.nocknock"
+ minSdkVersion versions.minSdk
+ targetSdkVersion versions.compileSdk
+ versionCode versions.publishVersionCode
+ versionName versions.publishVersion
+ }
}
dependencies {
- compile 'com.android.support:appcompat-v7:' + versions.supportLib
- compile 'com.android.support:design:' + versions.supportLib
- compile 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
- compile 'com.afollestad.material-dialogs:commons:' + versions.materialDialogs
- compile 'com.afollestad:bridge:' + versions.bridge
- compile 'com.afollestad:inquiry:' + versions.inquiry
- compile files('libs/rhino-1.7.7.1.jar')
-}
\ No newline at end of file
+ implementation project(':data')
+ implementation project(':utilities')
+ implementation project(':engine')
+ implementation project(':notifications')
+
+ implementation 'androidx.appcompat:appcompat:' + versions.androidx
+ implementation 'androidx.recyclerview:recyclerview:' + versions.androidx
+ implementation 'com.google.android.material:material:' + versions.androidx
+
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
+
+ implementation 'com.google.dagger:dagger:' + versions.dagger
+ kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
+
+ implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
+}
+
+apply from: '../spotless.gradle'
\ 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..cac0668 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,60 +3,49 @@
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/App.kt b/app/src/main/java/com/afollestad/nocknock/App.kt
new file mode 100644
index 0000000..06e17bd
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/App.kt
@@ -0,0 +1,56 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock
+
+import android.app.Application
+import android.app.NotificationManager
+import android.app.job.JobScheduler
+import android.content.Context
+import com.afollestad.nocknock.di.AppComponent
+import com.afollestad.nocknock.di.DaggerAppComponent
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
+import com.afollestad.nocknock.ui.AddSiteActivity
+import com.afollestad.nocknock.ui.MainActivity
+import com.afollestad.nocknock.ui.ViewSiteActivity
+import com.afollestad.nocknock.utilities.Injector
+import okhttp3.OkHttpClient
+
+/** @author Aidan Follestad (afollestad) */
+class App : Application(), Injector {
+
+ lateinit var appComponent: AppComponent
+
+ override fun onCreate() {
+ super.onCreate()
+
+ val okHttpClient = OkHttpClient.Builder()
+ .addNetworkInterceptor { chain ->
+ val request = chain.request()
+ .newBuilder()
+ .addHeader("User-Agent", "com.afollestad.nocknock")
+ .build()
+ chain.proceed(request)
+ }
+ .build()
+ val jobScheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ appComponent = DaggerAppComponent.builder()
+ .application(this)
+ .okHttpClient(okHttpClient)
+ .jobScheduler(jobScheduler)
+ .notificationManager(notificationManager)
+ .build()
+ }
+
+ override fun injectInto(target: Any) = when (target) {
+ is MainActivity -> appComponent.inject(target)
+ is ViewSiteActivity -> appComponent.inject(target)
+ is AddSiteActivity -> appComponent.inject(target)
+ is CheckStatusJob -> appComponent.inject(target)
+ else -> throw IllegalStateException("Can't inject into $target")
+ }
+}
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 e64c570..0000000
--- a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.java
+++ /dev/null
@@ -1,169 +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);
- }
-
- 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);
- }
-
- private 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;
-
- ServerVH(View itemView, ServerAdapter adapter) {
- super(itemView);
- iconStatus = itemView.findViewById(R.id.iconStatus);
- textName = itemView.findViewById(R.id.textName);
- textInterval = itemView.findViewById(R.id.textInterval);
- textUrl = itemView.findViewById(R.id.textUrl);
- textStatus = 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/ServerAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt
new file mode 100644
index 0000000..c390b81
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt
@@ -0,0 +1,137 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.adapter
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.ServerModel
+import com.afollestad.nocknock.data.textRes
+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: ServerModel, longClick: Boolean) -> Unit
+
+/** @author Aidan Follestad (afollestad) */
+class ServerVH constructor(
+ itemView: View,
+ private val adapter: ServerAdapter
+) : RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener {
+
+ init {
+ itemView.setOnClickListener(this)
+ itemView.setOnLongClickListener(this)
+ }
+
+ fun bind(model: ServerModel) {
+ itemView.textName.text = model.name
+ itemView.textUrl.text = model.url
+ itemView.iconStatus.setStatus(model.status)
+
+ val statusText = model.status.textRes()
+ if (statusText == 0) {
+ itemView.textStatus.text = model.reason
+ } else {
+ itemView.textStatus.setText(statusText)
+ }
+
+ itemView.textInterval.text = model.intervalText()
+ }
+
+ override fun onClick(view: View) {
+ adapter.performClick(adapterPosition, false)
+ }
+
+ override fun onLongClick(view: View): Boolean {
+ adapter.performClick(adapterPosition, true)
+ return false
+ }
+}
+
+/** @author Aidan Follestad (afollestad) */
+class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter() {
+
+ private val models = mutableListOf()
+
+ internal fun performClick(
+ index: Int,
+ longClick: Boolean
+ ) = listener.invoke(models[index], longClick)
+
+ fun add(model: ServerModel) {
+ models.add(model)
+ notifyItemInserted(models.size - 1)
+ }
+
+ fun update(target: ServerModel) {
+ for ((i, model) in models.withIndex()) {
+ if (model.id == target.id) {
+ update(i, target)
+ break
+ }
+ }
+ }
+
+ private fun update(
+ index: Int,
+ model: ServerModel
+ ) {
+ models[index] = model
+ notifyItemChanged(index)
+ }
+
+ fun remove(index: Int) {
+ models.removeAt(index)
+ notifyItemRemoved(index)
+ }
+
+ fun remove(target: ServerModel) {
+ for ((i, model) in models.withIndex()) {
+ if (model.id == target.id) {
+ remove(i)
+ break
+ }
+ }
+ }
+
+ fun set(newModels: List) {
+ this.models.clear()
+ if (newModels.isEmpty()) {
+ return
+ }
+ this.models.addAll(newModels)
+ notifyDataSetChanged()
+ }
+
+ fun clear() {
+ models.clear()
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): ServerVH {
+ val v = LayoutInflater.from(parent.context)
+ .inflate(R.layout.list_item_server, parent, false)
+ return ServerVH(v, this)
+ }
+
+ override fun onBindViewHolder(
+ holder: ServerVH,
+ position: Int
+ ) {
+ val model = models[position]
+ holder.bind(model)
+ }
+
+ override fun getItemCount() = models.size
+}
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 de6c2a6..0000000
--- a/app/src/main/java/com/afollestad/nocknock/api/ServerModel.java
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.afollestad.nocknock.api;
-
-import static com.afollestad.nocknock.ui.MainActivity.SITES_TABLE_NAME;
-
-import com.afollestad.inquiry.annotations.Column;
-import com.afollestad.inquiry.annotations.Table;
-import java.io.Serializable;
-
-/** @author Aidan Follestad (afollestad) */
-@Table(name = SITES_TABLE_NAME)
-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;
-}
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 93bf2c8..0000000
--- a/app/src/main/java/com/afollestad/nocknock/api/ServerStatus.java
+++ /dev/null
@@ -1,18 +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 static final int OK = 1;
- public static final int WAITING = 2;
- public static final int CHECKING = 3;
- public static final 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 bf1a524..0000000
--- a/app/src/main/java/com/afollestad/nocknock/api/ValidationMode.java
+++ /dev/null
@@ -1,17 +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 static final int STATUS_CODE = 1;
- public static final int TERM_SEARCH = 2;
- public static final 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/di/AppComponent.kt b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt
new file mode 100644
index 0000000..1346a67
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt
@@ -0,0 +1,56 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.di
+
+import android.app.Application
+import android.app.NotificationManager
+import android.app.job.JobScheduler
+import com.afollestad.nocknock.engine.EngineModule
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
+import com.afollestad.nocknock.notifications.NotificationsModule
+import com.afollestad.nocknock.ui.AddSiteActivity
+import com.afollestad.nocknock.ui.MainActivity
+import com.afollestad.nocknock.ui.ViewSiteActivity
+import com.afollestad.nocknock.utilities.UtilitiesModule
+import dagger.BindsInstance
+import dagger.Component
+import okhttp3.OkHttpClient
+import javax.inject.Singleton
+
+/** @author Aidan Follestad (afollestad) */
+@Singleton
+@Component(
+ modules = [
+ MainModule::class,
+ EngineModule::class,
+ NotificationsModule::class,
+ UtilitiesModule::class
+ ]
+)
+interface AppComponent {
+
+ fun inject(activity: MainActivity)
+
+ fun inject(activity: ViewSiteActivity)
+
+ fun inject(activity: AddSiteActivity)
+
+ fun inject(job: CheckStatusJob)
+
+ @Component.Builder
+ interface Builder {
+
+ @BindsInstance fun application(application: Application): Builder
+
+ @BindsInstance fun okHttpClient(okHttpClient: OkHttpClient): Builder
+
+ @BindsInstance fun jobScheduler(jobScheduler: JobScheduler): Builder
+
+ @BindsInstance fun notificationManager(notificationManager: NotificationManager): Builder
+
+ fun build(): AppComponent
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt
new file mode 100644
index 0000000..97f850b
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt
@@ -0,0 +1,22 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.di
+
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.utilities.qualifiers.AppIconRes
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+/** @author Aidan Follestad (afollestad) */
+@Module
+open class MainModule {
+
+ @Provides
+ @Singleton
+ @AppIconRes
+ fun provideAppIconRes(): Int = R.mipmap.ic_launcher
+}
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 e61ed75..0000000
--- a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.java
+++ /dev/null
@@ -1,30 +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();
- }
-}
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..2577e86
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt
@@ -0,0 +1,32 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+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.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 {
+ return MaterialDialog(activity!!)
+ .title(R.string.about)
+ .positiveButton(R.string.dismiss)
+ .message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
+ }
+}
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 29b70cf..0000000
--- a/app/src/main/java/com/afollestad/nocknock/receivers/BootReceiver.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.afollestad.nocknock.receivers;
-
-import android.annotation.SuppressLint;
-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 {
-
- @SuppressLint("VisibleForTests")
- @Override
- public void onReceive(Context context, Intent intent) {
- if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
- final Inquiry inq = Inquiry.newInstance(context, MainActivity.DB_NAME).build(false);
- ServerModel[] models = inq.select(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 3417ce3..0000000
--- a/app/src/main/java/com/afollestad/nocknock/receivers/ConnectivityReceiver.java
+++ /dev/null
@@ -1,22 +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 9924733..0000000
--- a/app/src/main/java/com/afollestad/nocknock/services/CheckService.java
+++ /dev/null
@@ -1,235 +0,0 @@
-package com.afollestad.nocknock.services;
-
-import android.annotation.SuppressLint;
-import android.app.IntentService;
-import android.app.Notification;
-import android.app.PendingIntent;
-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 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")
-@SuppressLint("VisibleForTests")
-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(ServerModel.class).values(new ServerModel[] {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)
- .apply();
- }
-
- 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)
- .apply();
- }
-
- 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 0b30163..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.java
+++ /dev/null
@@ -1,275 +0,0 @@
-package com.afollestad.nocknock.ui;
-
-import android.animation.Animator;
-import android.animation.AnimatorListenerAdapter;
-import android.annotation.SuppressLint;
-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;
-
- @SuppressLint("SetTextI18n")
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_addsite);
-
- rootLayout = findViewById(R.id.rootView);
- nameTiLayout = findViewById(R.id.nameTiLayout);
- inputName = findViewById(R.id.inputName);
- urlTiLayout = findViewById(R.id.urlTiLayout);
- inputUrl = findViewById(R.id.inputUrl);
- textUrlWarning = findViewById(R.id.textUrlWarning);
- inputInterval = findViewById(R.id.checkIntervalInput);
- spinnerInterval = findViewById(R.id.checkIntervalSpinner);
- responseValidationSpinner = findViewById(R.id.responseValidationMode);
-
- 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 = 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);
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.kt
new file mode 100644
index 0000000..580b693
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.kt
@@ -0,0 +1,278 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.ui
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION
+import android.net.Uri
+import android.os.Bundle
+import android.util.Patterns.WEB_URL
+import android.view.View
+import android.view.ViewAnimationUtils.createCircularReveal
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.DecelerateInterpolator
+import android.widget.ArrayAdapter
+import androidx.appcompat.app.AppCompatActivity
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.ServerModel
+import com.afollestad.nocknock.data.ServerStatus.WAITING
+import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
+import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
+import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
+import com.afollestad.nocknock.engine.db.ServerModelStore
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
+import com.afollestad.nocknock.utilities.ext.conceal
+import com.afollestad.nocknock.utilities.ext.hide
+import com.afollestad.nocknock.utilities.ext.injector
+import com.afollestad.nocknock.utilities.ext.onEnd
+import com.afollestad.nocknock.utilities.ext.onItemSelected
+import com.afollestad.nocknock.utilities.ext.onLayout
+import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
+import com.afollestad.nocknock.utilities.ext.show
+import com.afollestad.nocknock.utilities.ext.showOrHide
+import com.afollestad.nocknock.utilities.ext.textAsLong
+import com.afollestad.nocknock.utilities.ext.trimmedText
+import kotlinx.android.synthetic.main.activity_addsite.checkIntervalInput
+import kotlinx.android.synthetic.main.activity_addsite.checkIntervalSpinner
+import kotlinx.android.synthetic.main.activity_addsite.doneBtn
+import kotlinx.android.synthetic.main.activity_addsite.inputName
+import kotlinx.android.synthetic.main.activity_addsite.inputUrl
+import kotlinx.android.synthetic.main.activity_addsite.nameTiLayout
+import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode
+import kotlinx.android.synthetic.main.activity_addsite.responseValidationScript
+import kotlinx.android.synthetic.main.activity_addsite.responseValidationScriptInput
+import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
+import kotlinx.android.synthetic.main.activity_addsite.rootView
+import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
+import kotlinx.android.synthetic.main.activity_addsite.toolbar
+import kotlinx.android.synthetic.main.activity_addsite.urlTiLayout
+import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import java.lang.System.currentTimeMillis
+import javax.inject.Inject
+import kotlin.math.max
+
+private const val KEY_FAB_X = "fab_x"
+private const val KEY_FAB_Y = "fab_y"
+private const val KEY_FAB_SIZE = "fab_size"
+
+/** @author Aidan Follestad (afollestad) */
+fun MainActivity.intentToAdd(
+ x: Float,
+ y: Float,
+ size: Int
+) = Intent(this, AddSiteActivity::class.java).apply {
+ putExtra(KEY_FAB_X, x)
+ putExtra(KEY_FAB_Y, y)
+ putExtra(KEY_FAB_SIZE, size)
+ addFlags(FLAG_ACTIVITY_NO_ANIMATION)
+}
+
+/** @author Aidan Follestad (afollestad) */
+class AddSiteActivity : AppCompatActivity(), View.OnClickListener {
+
+ private var isClosing: Boolean = false
+
+ @Inject lateinit var serverModelStore: ServerModelStore
+ @Inject lateinit var checkStatusManager: CheckStatusManager
+
+ @SuppressLint("SetTextI18n")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ injector().injectInto(this)
+
+ setContentView(R.layout.activity_addsite)
+ toolbar.setNavigationOnClickListener { closeActivityWithReveal() }
+
+ if (savedInstanceState == null) {
+ rootView.conceal()
+ rootView.onLayout { circularRevealActivity() }
+ }
+
+ val intervalOptionsAdapter = ArrayAdapter(
+ this,
+ R.layout.list_item_spinner,
+ resources.getStringArray(R.array.interval_options)
+ )
+ intervalOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
+ checkIntervalSpinner.adapter = intervalOptionsAdapter
+
+ inputUrl.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) {
+ val inputStr = inputUrl.text
+ .toString()
+ .trim()
+ if (inputStr.isEmpty()) {
+ return@setOnFocusChangeListener
+ }
+
+ val uri = Uri.parse(inputStr)
+ if (uri.scheme == null) {
+ inputUrl.setText("http://$inputStr")
+ textUrlWarning.hide()
+ } else if ("http" != uri.scheme && "https" != uri.scheme) {
+ textUrlWarning.show()
+ textUrlWarning.setText(R.string.warning_http_url)
+ } else {
+ textUrlWarning.hide()
+ }
+ }
+ }
+
+ 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
+ responseValidationMode.onItemSelected { pos ->
+ responseValidationSearchTerm.showOrHide(pos == 1)
+ responseValidationScript.showOrHide(pos == 2)
+
+ validationModeDescription.setText(
+ when (pos) {
+ 0 -> R.string.validation_mode_status_desc
+ 1 -> R.string.validation_mode_term_desc
+ 2 -> R.string.validation_mode_javascript_desc
+ else -> throw IllegalStateException("Unknown validation mode position: $pos")
+ }
+ )
+ }
+
+ doneBtn.setOnClickListener(this)
+ }
+
+ private fun closeActivityWithReveal() {
+ if (isClosing) {
+ return
+ }
+
+ isClosing = true
+ val fabSize = intent.getIntExtra(KEY_FAB_SIZE, toolbar!!.measuredHeight)
+
+ val defaultCx = rootView.measuredWidth / 2f
+ val cx =
+ intent.getFloatExtra(KEY_FAB_X, defaultCx).toInt() + fabSize / 2
+
+ val defaultCy = rootView.measuredHeight / 2f
+ val cy = (intent.getFloatExtra(KEY_FAB_Y, defaultCy).toInt() +
+ toolbar!!.measuredHeight +
+ fabSize / 2)
+
+ val initialRadius = max(cx, cy).toFloat()
+ createCircularReveal(rootView, cx, cy, initialRadius, 0f)
+ .apply {
+ duration = 300
+ interpolator = AccelerateInterpolator()
+ onEnd {
+ rootView.conceal()
+ finish()
+ overridePendingTransition(0, 0)
+ }
+ start()
+ }
+ }
+
+ private fun circularRevealActivity() {
+ val cx = rootView.measuredWidth / 2
+ val cy = rootView.measuredHeight / 2
+ val finalRadius = Math.max(cx, cy)
+ .toFloat()
+ val circularReveal = createCircularReveal(rootView, cx, cy, 0f, finalRadius)
+ .apply {
+ duration = 300
+ interpolator = DecelerateInterpolator()
+ }
+ rootView.show()
+ circularReveal.start()
+ }
+
+ // Done button
+ override fun onClick(view: View) {
+ isClosing = true
+
+ var model = ServerModel(
+ name = inputName.trimmedText(),
+ url = inputUrl.trimmedText(),
+ status = WAITING,
+ validationMode = STATUS_CODE
+ )
+
+ if (model.name.isEmpty()) {
+ nameTiLayout.error = getString(R.string.please_enter_name)
+ isClosing = false
+ return
+ } else {
+ nameTiLayout.error = null
+ }
+
+ if (model.url.isEmpty()) {
+ urlTiLayout.error = getString(R.string.please_enter_url)
+ isClosing = false
+ return
+ } else {
+ urlTiLayout.error = null
+ if (!WEB_URL.matcher(model.url).find()) {
+ urlTiLayout.error = getString(R.string.please_enter_valid_url)
+ isClosing = false
+ return
+ } else {
+ val uri = Uri.parse(model.url)
+ if (uri.scheme == null) {
+ model = model.copy(url = "http://${model.url}")
+ }
+ }
+ }
+
+ val intervalValue = checkIntervalInput.textAsLong()
+
+ model = when (checkIntervalSpinner.selectedItemPosition) {
+ 0 -> model.copy(checkInterval = intervalValue * (60 * 1000))
+ 1 -> model.copy(checkInterval = intervalValue * (60 * 60 * 1000))
+ 2 -> model.copy(checkInterval = intervalValue * (60 * 60 * 24 * 1000))
+ else -> model.copy(checkInterval = intervalValue * (60 * 60 * 24 * 7 * 1000))
+ }
+ model = model.copy(lastCheck = currentTimeMillis() - model.checkInterval)
+
+ when (responseValidationMode.selectedItemPosition) {
+ 0 -> {
+ model = model.copy(validationMode = STATUS_CODE, validationContent = null)
+ }
+ 1 -> {
+ model = model.copy(
+ validationMode = TERM_SEARCH,
+ validationContent = responseValidationSearchTerm.trimmedText()
+ )
+ }
+ 2 -> {
+ model = model.copy(
+ validationMode = JAVASCRIPT,
+ validationContent = responseValidationScriptInput.trimmedText()
+ )
+ }
+ }
+
+ rootView.scopeWhileAttached(Main) {
+ launch(coroutineContext) {
+ val storedModel = async(IO) { serverModelStore.put(model) }.await()
+ checkStatusManager.cancelCheck(storedModel)
+ checkStatusManager.scheduleCheck(storedModel, rightNow = true)
+
+ setResult(RESULT_OK)
+ finish()
+ overridePendingTransition(R.anim.fade_out, R.anim.fade_out)
+ }
+ }
+ }
+
+ override fun onBackPressed() = closeActivityWithReveal()
+}
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 64a0add..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.java
+++ /dev/null
@@ -1,319 +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.dialogs.AboutDialog;
-import com.afollestad.nocknock.services.CheckService;
-import com.afollestad.nocknock.util.AlarmUtil;
-import com.afollestad.nocknock.util.MathUtil;
-
-@SuppressLint("VisibleForTests")
-public class MainActivity extends AppCompatActivity
- implements SwipeRefreshLayout.OnRefreshListener,
- View.OnClickListener,
- ServerAdapter.ClickListener {
-
- private static final int ADD_SITE_RQ = 6969;
- private static final int VIEW_SITE_RQ = 6923;
- public static final String DB_NAME = "nock_nock";
- public static final 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 = findViewById(R.id.emptyText);
-
- mList = findViewById(R.id.list);
- mList.setLayoutManager(new LinearLayoutManager(this));
- mList.setAdapter(mAdapter);
- mList.addItemDecoration(
- new android.support.v7.widget.DividerItemDecoration(
- this, android.support.v7.widget.DividerItemDecoration.VERTICAL));
-
- mRefreshLayout = 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 = findViewById(R.id.fab);
- mFab.setOnClickListener(this);
-
- Inquiry.newInstance(this, DB_NAME).build();
- Bridge.config().defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)");
- }
-
- 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).apply();
- 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(new ServerModel[] {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.delete(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));
- }
-
- @SuppressLint("RestrictedApi")
- @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());
- }
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt
new file mode 100644
index 0000000..adb119b
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.kt
@@ -0,0 +1,260 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.ui
+
+import android.animation.ObjectAnimator
+import android.animation.ObjectAnimator.ofFloat
+import android.annotation.SuppressLint
+import android.app.ActivityOptions.makeSceneTransitionAnimation
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.View.X
+import android.view.View.Y
+import android.view.animation.PathInterpolator
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.text.HtmlCompat
+import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.afollestad.materialdialogs.MaterialDialog
+import com.afollestad.materialdialogs.list.listItems
+import com.afollestad.nocknock.BuildConfig
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.adapter.ServerAdapter
+import com.afollestad.nocknock.data.ServerModel
+import com.afollestad.nocknock.dialogs.AboutDialog
+import com.afollestad.nocknock.engine.db.ServerModelStore
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.utilities.ext.injector
+import com.afollestad.nocknock.utilities.ext.onEnd
+import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
+import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
+import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
+import com.afollestad.nocknock.utilities.ext.show
+import com.afollestad.nocknock.utilities.ext.showOrHide
+import com.afollestad.nocknock.utilities.util.MathUtil.bezierCurve
+import kotlinx.android.synthetic.main.activity_main.emptyText
+import kotlinx.android.synthetic.main.activity_main.fab
+import kotlinx.android.synthetic.main.activity_main.list
+import kotlinx.android.synthetic.main.activity_main.rootView
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/** @author Aidan Follestad (afollestad) */
+class MainActivity : AppCompatActivity(), View.OnClickListener {
+
+ companion object {
+ private const val ADD_SITE_RQ = 6969
+ private const val VIEW_SITE_RQ = 6923
+ private const val REVEAL_DURATION = 250L
+
+ private fun log(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d("MainActivity", message)
+ }
+ }
+ }
+
+ private var fabAnimator: ObjectAnimator? = null
+ private var originalFabX: Float = 0.toFloat()
+ private var originalFabY: Float = 0.toFloat()
+
+ private val intentReceiver = object : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent
+ ) {
+ log("Received broadcast ${intent.action}")
+ when (intent.action) {
+ ACTION_STATUS_UPDATE -> {
+ val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
+ list.post { adapter.update(model) }
+ }
+ else -> throw IllegalStateException("Unexpected intent: ${intent.action}")
+ }
+ }
+ }
+
+ @Inject lateinit var serverModelStore: ServerModelStore
+ @Inject lateinit var notificationManager: NockNotificationManager
+ @Inject lateinit var checkStatusManager: CheckStatusManager
+
+ private lateinit var adapter: ServerAdapter
+
+ @SuppressLint("CommitPrefEdits")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ injector().injectInto(this)
+ setContentView(R.layout.activity_main)
+
+ adapter = ServerAdapter(this::onSiteSelected)
+
+ list.layoutManager = LinearLayoutManager(this)
+ list.adapter = adapter
+ list.addItemDecoration(DividerItemDecoration(this, VERTICAL))
+
+ fab.setOnClickListener(this)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ notificationManager.setIsAppOpen(true)
+
+ val filter = IntentFilter().apply {
+ addAction(ACTION_STATUS_UPDATE)
+ }
+ safeRegisterReceiver(intentReceiver, filter)
+
+ refreshModels()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ notificationManager.setIsAppOpen(false)
+ safeUnregisterReceiver(intentReceiver)
+ }
+
+ private fun refreshModels() {
+ adapter.clear()
+ emptyText.show()
+ rootView.scopeWhileAttached(Main) {
+ launch(coroutineContext) {
+ val models = async(IO) { serverModelStore.get() }.await()
+ adapter.set(models)
+ emptyText.showOrHide(adapter.itemCount == 0)
+ }
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_main, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ if (item.itemId == R.id.about) {
+ AboutDialog.show(this)
+ return true
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ // FAB clicked
+ override fun onClick(view: View) {
+ originalFabX = fab.x
+ originalFabY = fab.y
+
+ fabAnimator?.cancel()
+ fabAnimator = ofFloat(view, X, Y, bezierCurve(fab, list))
+ .apply {
+ interpolator = PathInterpolator(.5f, .5f)
+ duration = REVEAL_DURATION
+ onEnd {
+ startActivityForResult(
+ intentToAdd(originalFabX, originalFabY, fab.measuredWidth),
+ ADD_SITE_RQ
+ )
+ fab.postDelayed(
+ {
+ fab.x = originalFabX
+ fab.y = originalFabY
+ },
+ REVEAL_DURATION * 2
+ )
+ }
+ start()
+ }
+ }
+
+ override fun onActivityResult(
+ requestCode: Int,
+ resultCode: Int,
+ data: Intent?
+ ) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (resultCode == RESULT_OK) {
+ refreshModels()
+ }
+ }
+
+ private fun onSiteSelected(
+ model: ServerModel,
+ longClick: Boolean
+ ) {
+ if (longClick) {
+ MaterialDialog(this).show {
+ title(R.string.options)
+ listItems(R.array.site_long_options) { _, i, _ ->
+ when (i) {
+ 0 -> {
+ checkStatusManager.cancelCheck(model)
+ checkStatusManager.scheduleCheck(model)
+ }
+ 1 -> maybeRemoveSite(model) {
+ adapter.remove(i)
+ emptyText.showOrHide(adapter.itemCount == 0)
+ }
+ else -> throw IllegalStateException("Unexpected index: $i")
+ }
+ }
+ negativeButton(android.R.string.cancel)
+ }
+ return
+ }
+
+ startActivityForResult(
+ intentToView(model),
+ VIEW_SITE_RQ,
+ makeSceneTransitionAnimation(this).toBundle()
+ )
+ }
+
+ private fun maybeRemoveSite(
+ model: ServerModel,
+ onRemoved: (() -> Unit)?
+ ) {
+ MaterialDialog(this).show {
+ title(R.string.remove_site)
+ message(
+ text = HtmlCompat.fromHtml(
+ context.getString(R.string.remove_site_prompt, model.name), FROM_HTML_MODE_LEGACY
+ )
+ )
+ positiveButton(R.string.remove) {
+ checkStatusManager.cancelCheck(model)
+ notificationManager.cancelStatusNotifications()
+ performRemoveSite(model, onRemoved)
+ }
+ negativeButton(android.R.string.cancel)
+ }
+ }
+
+ private fun performRemoveSite(
+ model: ServerModel,
+ onRemoved: (() -> Unit)?
+ ) {
+ rootView.scopeWhileAttached(Main) {
+ launch(coroutineContext) {
+ async(IO) { serverModelStore.delete(model) }.await()
+ onRemoved?.invoke()
+ }
+ }
+ }
+}
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 f3e6b60..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.java
+++ /dev/null
@@ -1,359 +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();
- }
- }
- };
-
- @SuppressLint("SetTextI18n")
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_viewsite);
-
- final Toolbar toolbar = findViewById(R.id.toolbar);
- toolbar.setNavigationOnClickListener(view -> finish());
- toolbar.inflateMenu(R.menu.menu_viewsite);
- toolbar.setOnMenuItemClickListener(this);
-
- iconStatus = findViewById(R.id.iconStatus);
- inputName = findViewById(R.id.inputName);
- inputUrl = findViewById(R.id.inputUrl);
- textUrlWarning = findViewById(R.id.textUrlWarning);
- inputCheckInterval = findViewById(R.id.checkIntervalInput);
- checkIntervalSpinner = findViewById(R.id.checkIntervalSpinner);
- textLastCheckResult = findViewById(R.id.textLastCheckResult);
- textNextCheck = findViewById(R.id.textNextCheck);
- responseValidationSpinner = 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 = 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();
- }
- }
-
- @SuppressLint("VisibleForTests")
- 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(ServerModel.class).values(new ServerModel[] {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/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.kt
new file mode 100644
index 0000000..df5f19f
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.kt
@@ -0,0 +1,416 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+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.util.Log
+import android.util.Patterns.WEB_URL
+import android.view.MenuItem
+import android.view.View
+import android.widget.ArrayAdapter
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.Toolbar
+import androidx.core.text.HtmlCompat
+import com.afollestad.materialdialogs.MaterialDialog
+import com.afollestad.nocknock.BuildConfig
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.ServerModel
+import com.afollestad.nocknock.data.ServerStatus.WAITING
+import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
+import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
+import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
+import com.afollestad.nocknock.data.textRes
+import com.afollestad.nocknock.engine.db.ServerModelStore
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.utilities.ext.DAY
+import com.afollestad.nocknock.utilities.ext.HOUR
+import com.afollestad.nocknock.utilities.ext.MINUTE
+import com.afollestad.nocknock.utilities.ext.WEEK
+import com.afollestad.nocknock.utilities.ext.formatDate
+import com.afollestad.nocknock.utilities.ext.hide
+import com.afollestad.nocknock.utilities.ext.injector
+import com.afollestad.nocknock.utilities.ext.isHttpOrHttps
+import com.afollestad.nocknock.utilities.ext.onItemSelected
+import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
+import com.afollestad.nocknock.utilities.ext.show
+import com.afollestad.nocknock.utilities.ext.showOrHide
+import com.afollestad.nocknock.utilities.ext.textAsLong
+import com.afollestad.nocknock.utilities.ext.trimmedText
+import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalInput
+import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalSpinner
+import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
+import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
+import kotlinx.android.synthetic.main.activity_viewsite.inputName
+import kotlinx.android.synthetic.main.activity_viewsite.inputUrl
+import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
+import kotlinx.android.synthetic.main.activity_viewsite.responseValidationScript
+import kotlinx.android.synthetic.main.activity_viewsite.responseValidationScriptInput
+import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
+import kotlinx.android.synthetic.main.activity_viewsite.rootView
+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.toolbar
+import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import java.lang.System.currentTimeMillis
+import javax.inject.Inject
+import kotlin.math.ceil
+
+private const val KEY_VIEW_MODEL = "site_model"
+
+/** @author Aidan Follestad (afViewSiteActivityollestad) */
+fun MainActivity.intentToView(model: ServerModel) =
+ Intent(this, ViewSiteActivity::class.java).apply {
+ putExtra(KEY_VIEW_MODEL, model)
+ }
+
+/** @author Aidan Follestad (afollestad) */
+class ViewSiteActivity : AppCompatActivity(),
+ View.OnClickListener,
+ Toolbar.OnMenuItemClickListener {
+ companion object {
+ private fun log(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d("ViewSiteActivity", message)
+ }
+ }
+ }
+
+ private lateinit var currentModel: ServerModel
+
+ @Inject lateinit var serverModelStore: ServerModelStore
+ @Inject lateinit var notificationManager: NockNotificationManager
+ @Inject lateinit var checkStatusManager: CheckStatusManager
+
+ private val intentReceiver = object : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent
+ ) {
+ log("Received broadcast ${intent.action}")
+ val model = intent.getSerializableExtra(KEY_VIEW_MODEL) as? ServerModel
+ if (model != null) {
+ this@ViewSiteActivity.currentModel = model
+ update()
+ }
+ }
+ }
+
+ @SuppressLint("SetTextI18n")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ injector().injectInto(this)
+ setContentView(R.layout.activity_viewsite)
+
+ toolbar.run {
+ setNavigationOnClickListener { finish() }
+ inflateMenu(R.menu.menu_viewsite)
+ setOnMenuItemClickListener(this@ViewSiteActivity)
+ }
+
+ val intervalOptionsAdapter = ArrayAdapter(
+ this,
+ R.layout.list_item_spinner,
+ resources.getStringArray(R.array.interval_options)
+ )
+ intervalOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
+ checkIntervalSpinner.adapter = intervalOptionsAdapter
+
+ inputUrl.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) {
+ val inputStr = inputUrl.text
+ .toString()
+ .trim()
+ if (inputStr.isEmpty()) {
+ return@setOnFocusChangeListener
+ }
+
+ val uri = Uri.parse(inputStr)
+ if (uri.scheme == null) {
+ inputUrl.setText("http://$inputStr")
+ textUrlWarning.hide()
+ } else if (!uri.isHttpOrHttps()) {
+ textUrlWarning.show()
+ textUrlWarning.setText(R.string.warning_http_url)
+ } else {
+ textUrlWarning.hide()
+ }
+ }
+ }
+
+ 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
+
+ responseValidationMode.onItemSelected { pos ->
+ responseValidationSearchTerm.showOrHide(pos == 1)
+ responseValidationScript.showOrHide(pos == 2)
+
+ validationModeDescription.setText(
+ when (pos) {
+ 0 -> R.string.validation_mode_status_desc
+ 1 -> R.string.validation_mode_term_desc
+ 2 -> R.string.validation_mode_javascript_desc
+ else -> throw IllegalStateException("Unexpected position: $pos")
+ }
+ )
+ }
+
+ currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
+ update()
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) {
+ currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
+ update()
+ }
+ }
+
+ @SuppressLint("SetTextI18n")
+ private fun update() = with(currentModel) {
+ iconStatus.setStatus(this.status)
+ inputName.setText(this.name)
+ inputUrl.setText(this.url)
+
+ if (this.lastCheck == 0L) {
+ textLastCheckResult.setText(R.string.none)
+ } else {
+ val statusText = this.status.textRes()
+ textLastCheckResult.text = if (statusText == 0) {
+ this.reason
+ } else {
+ getString(statusText)
+ }
+ }
+
+ if (this.checkInterval == 0L) {
+ textNextCheck.setText(R.string.none_turned_off)
+ checkIntervalInput.setText("")
+ checkIntervalSpinner.setSelection(0)
+ } else {
+ var lastCheck = this.lastCheck
+ if (lastCheck == 0L) {
+ lastCheck = currentTimeMillis()
+ }
+ textNextCheck.text = (lastCheck + this.checkInterval).formatDate()
+
+ when {
+ this.checkInterval >= WEEK -> {
+ checkIntervalInput.setText(
+ ceil((this.checkInterval.toFloat() / WEEK).toDouble()).toInt().toString()
+ )
+ checkIntervalSpinner.setSelection(3)
+ }
+ this.checkInterval >= DAY -> {
+ checkIntervalInput.setText(
+ ceil((this.checkInterval.toFloat() / DAY.toFloat()).toDouble()).toInt().toString()
+ )
+ checkIntervalSpinner.setSelection(2)
+ }
+ this.checkInterval >= HOUR -> {
+ checkIntervalInput.setText(
+ ceil((this.checkInterval.toFloat() / HOUR.toFloat()).toDouble()).toInt().toString()
+ )
+ checkIntervalSpinner.setSelection(1)
+ }
+ this.checkInterval >= MINUTE -> {
+ checkIntervalInput.setText(
+ ceil((this.checkInterval.toFloat() / MINUTE.toFloat()).toDouble()).toInt().toString()
+ )
+ checkIntervalSpinner.setSelection(0)
+ }
+ else -> {
+ checkIntervalInput.setText("0")
+ checkIntervalSpinner.setSelection(0)
+ }
+ }
+ }
+
+ responseValidationMode.setSelection(validationMode.value - 1)
+
+ when (this.validationMode) {
+ TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "")
+ JAVASCRIPT -> responseValidationScriptInput.setText(this.validationContent ?: "")
+ else -> {
+ responseValidationSearchTerm.setText("")
+ responseValidationScriptInput.setText("")
+ }
+ }
+
+ doneBtn.setOnClickListener(this@ViewSiteActivity)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ try {
+ val filter = IntentFilter()
+ filter.addAction(ACTION_STATUS_UPDATE)
+ // filter.addAction(CheckService.ACTION_JOB_RUNNING);
+ registerReceiver(intentReceiver, filter)
+ } catch (t: Throwable) {
+ t.printStackTrace()
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ try {
+ unregisterReceiver(intentReceiver)
+ } catch (t: Throwable) {
+ t.printStackTrace()
+ }
+ }
+
+ private fun updateModelFromInput(withValidation: Boolean) {
+ currentModel = currentModel.copy(
+ name = inputName.trimmedText(),
+ url = inputUrl.trimmedText(),
+ status = WAITING
+ )
+
+ if (withValidation && currentModel.name.isEmpty()) {
+ inputName.error = getString(R.string.please_enter_name)
+ return
+ } else {
+ inputName.error = null
+ }
+
+ if (withValidation && currentModel.url.isEmpty()) {
+ inputUrl.error = getString(R.string.please_enter_url)
+ return
+ } else {
+ inputUrl.error = null
+ if (withValidation && !WEB_URL.matcher(currentModel.url).find()) {
+ inputUrl.error = getString(R.string.please_enter_valid_url)
+ return
+ } else {
+ val uri = Uri.parse(currentModel.url)
+ if (uri.scheme == null) {
+ currentModel = currentModel.copy(url = "http://${currentModel.url}")
+ }
+ }
+ }
+
+ val intervalValue = checkIntervalInput.textAsLong()
+
+ currentModel = when (checkIntervalSpinner.selectedItemPosition) {
+ 0 -> currentModel.copy(checkInterval = intervalValue * (60 * 1000))
+ 1 -> currentModel.copy(checkInterval = intervalValue * (60 * 60 * 1000))
+ 2 -> currentModel.copy(checkInterval = intervalValue * (60 * 60 * 24 * 1000))
+ else -> currentModel.copy(checkInterval = intervalValue * (60 * 60 * 24 * 7 * 1000))
+ }
+
+ currentModel = currentModel.copy(
+ lastCheck = currentTimeMillis() - currentModel.checkInterval
+ )
+
+ when (responseValidationMode.selectedItemPosition) {
+ 0 -> {
+ currentModel = currentModel.copy(
+ validationMode = STATUS_CODE,
+ validationContent = null
+ )
+ }
+ 1 -> {
+ currentModel = currentModel.copy(
+ validationMode = TERM_SEARCH,
+ validationContent = responseValidationSearchTerm.trimmedText()
+ )
+ }
+ 2 -> {
+ currentModel = currentModel.copy(
+ validationMode = JAVASCRIPT,
+ validationContent = responseValidationScriptInput.trimmedText()
+ )
+ }
+ }
+ }
+
+ // Save button
+ override fun onClick(view: View) {
+ rootView.scopeWhileAttached(Main) {
+ launch(coroutineContext) {
+ updateModelFromInput(true)
+ async(IO) { serverModelStore.update(currentModel) }.await()
+ setResult(RESULT_OK)
+ finish()
+ }
+ }
+ }
+
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.refresh -> {
+ rootView.scopeWhileAttached(Main) {
+ launch(coroutineContext) {
+ updateModelFromInput(false)
+ async(IO) { serverModelStore.update(currentModel) }.await()
+ checkStatusManager.cancelCheck(currentModel)
+ checkStatusManager.scheduleCheck(currentModel, rightNow = true)
+ }
+ }
+ return true
+ }
+ R.id.remove -> {
+ maybeRemoveSite(currentModel) { finish() }
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun maybeRemoveSite(
+ model: ServerModel,
+ onRemoved: (() -> Unit)?
+ ) {
+ MaterialDialog(this).show {
+ title(R.string.remove_site)
+ message(
+ text = HtmlCompat.fromHtml(
+ context.getString(R.string.remove_site_prompt, model.name),
+ HtmlCompat.FROM_HTML_MODE_LEGACY
+ )
+ )
+ positiveButton(R.string.remove) {
+ checkStatusManager.cancelCheck(model)
+ notificationManager.cancelStatusNotifications()
+ performRemoveSite(model, onRemoved)
+ }
+ negativeButton(android.R.string.cancel)
+ }
+ }
+
+ private fun performRemoveSite(
+ model: ServerModel,
+ onRemoved: (() -> Unit)?
+ ) {
+ rootView.scopeWhileAttached(Main) {
+ launch(coroutineContext) {
+ async(IO) { serverModelStore.delete(model) }.await()
+ onRemoved?.invoke()
+ }
+ }
+ }
+}
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 a527898..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/AlarmUtil.java
+++ /dev/null
@@ -1,61 +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 static final 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);
- }
-}
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 3c7c462..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/JsUtil.java
+++ /dev/null
@@ -1,68 +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 efe418b..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java
+++ /dev/null
@@ -1,30 +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;
- }
-}
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 c67e57a..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/NetworkUtil.java
+++ /dev/null
@@ -1,16 +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();
- }
-}
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 bc0b604..0000000
--- a/app/src/main/java/com/afollestad/nocknock/util/TimeUtil.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.afollestad.nocknock.util;
-
-/** @author Aidan Follestad (afollestad) */
-public class TimeUtil {
-
- public static final long SECOND = 1000;
- public static final long MINUTE = SECOND * 60;
- public static final long HOUR = MINUTE * 60;
- public static final long DAY = HOUR * 24;
- public static final long WEEK = DAY * 7;
- public static final 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/StatusImageView.java b/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java
deleted file mode 100644
index a9a7b21..0000000
--- a/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.afollestad.nocknock.views;
-
-import android.content.Context;
-import android.support.v7.widget.AppCompatImageView;
-import android.util.AttributeSet;
-import com.afollestad.nocknock.R;
-import com.afollestad.nocknock.api.ServerStatus;
-
-/** @author Aidan Follestad (afollestad) */
-public class StatusImageView extends AppCompatImageView {
-
- 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/java/com/afollestad/nocknock/views/StatusImageView.kt b/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.kt
new file mode 100644
index 0000000..a118d04
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.kt
@@ -0,0 +1,42 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.views
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.appcompat.widget.AppCompatImageView
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.ServerStatus
+import com.afollestad.nocknock.data.ServerStatus.CHECKING
+import com.afollestad.nocknock.data.ServerStatus.ERROR
+import com.afollestad.nocknock.data.ServerStatus.OK
+import com.afollestad.nocknock.data.ServerStatus.WAITING
+
+/** @author Aidan Follestad (afollestad) */
+class StatusImageView(
+ context: Context,
+ attrs: AttributeSet? = null
+) : AppCompatImageView(context, attrs) {
+
+ init {
+ setStatus(OK)
+ }
+
+ fun setStatus(status: ServerStatus) = when (status) {
+ CHECKING, WAITING -> {
+ setImageResource(R.drawable.status_progress)
+ setBackgroundResource(R.drawable.yellow_circle)
+ }
+ ERROR -> {
+ setImageResource(R.drawable.status_error)
+ setBackgroundResource(R.drawable.red_circle)
+ }
+ OK -> {
+ setImageResource(R.drawable.status_ok)
+ setBackgroundResource(R.drawable.green_circle)
+ }
+ }
+}
diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml
index 900d3cc..da8bafd 100644
--- a/app/src/main/res/anim/fade_out.xml
+++ b/app/src/main/res/anim/fade_out.xml
@@ -1,11 +1,9 @@
-
-
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml
index 98beb0c..9be83b4 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
index 31bc563..e02aec2 100644
--- a/app/src/main/res/drawable/green_circle.xml
+++ b/app/src/main/res/drawable/green_circle.xml
@@ -2,11 +2,11 @@
-
+
-
+
-
-
\ 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..369498e 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..7539f69 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..0240dcb 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_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
index d6bdd26..d2aecb2 100644
--- a/app/src/main/res/drawable/red_circle.xml
+++ b/app/src/main/res/drawable/red_circle.xml
@@ -2,11 +2,11 @@
-
+
-
+
-
-
\ 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
index 6f67037..e913358 100644
--- a/app/src/main/res/drawable/status_error.xml
+++ b/app/src/main/res/drawable/status_error.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/status_ok.xml b/app/src/main/res/drawable/status_ok.xml
index 3ec2bc9..fb118ed 100644
--- a/app/src/main/res/drawable/status_ok.xml
+++ b/app/src/main/res/drawable/status_ok.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/status_progress.xml b/app/src/main/res/drawable/status_progress.xml
index c100e60..ecdb7b3 100644
--- a/app/src/main/res/drawable/status_progress.xml
+++ b/app/src/main/res/drawable/status_progress.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/yellow_circle.xml b/app/src/main/res/drawable/yellow_circle.xml
index 1201e65..dfff479 100644
--- a/app/src/main/res/drawable/yellow_circle.xml
+++ b/app/src/main/res/drawable/yellow_circle.xml
@@ -2,11 +2,11 @@
-
+
-
+
-
-
\ 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..a03096c 100644
--- a/app/src/main/res/layout/activity_addsite.xml
+++ b/app/src/main/res/layout/activity_addsite.xml
@@ -1,242 +1,270 @@
-
+ android:orientation="vertical"
+ >
-
+
+
+
+
+ android:orientation="vertical"
+ android:paddingBottom="@dimen/content_inset"
+ android:paddingLeft="@dimen/content_inset"
+ android:paddingRight="@dimen/content_inset"
+ >
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
-
\ 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..8ce914d 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -2,104 +2,44 @@
+ tools:context=".ui.MainActivity"
+ >
-
+
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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..7697c61 100644
--- a/app/src/main/res/layout/activity_viewsite.xml
+++ b/app/src/main/res/layout/activity_viewsite.xml
@@ -6,290 +6,324 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?colorPrimary"
- android:orientation="vertical">
+ android:orientation="vertical"
+ >
-
+
+
+
+
+ android:orientation="vertical"
+ android:paddingBottom="@dimen/content_inset"
+ android:paddingLeft="@dimen/content_inset"
+ android:paddingRight="@dimen/content_inset"
+ android:paddingTop="@dimen/content_inset_half"
+ >
-
+
+
+
+ >
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_server.xml b/app/src/main/res/layout/list_item_server.xml
index 3e9ae2c..3f3244b 100644
--- a/app/src/main/res/layout/list_item_server.xml
+++ b/app/src/main/res/layout/list_item_server.xml
@@ -1,5 +1,6 @@
-
+ android:paddingTop="@dimen/content_inset_less"
+ >
-
+
-
+
+
+ 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
index 32960dd..b63045a 100644
--- a/app/src/main/res/layout/list_item_spinner.xml
+++ b/app/src/main/res/layout/list_item_spinner.xml
@@ -1,8 +1,9 @@
-
\ No newline at end of file
+ android:textSize="@dimen/body_font_size" />
diff --git a/app/src/main/res/layout/list_item_spinner_dropdown.xml b/app/src/main/res/layout/list_item_spinner_dropdown.xml
index 6b45ece..4d99ee8 100644
--- a/app/src/main/res/layout/list_item_spinner_dropdown.xml
+++ b/app/src/main/res/layout/list_item_spinner_dropdown.xml
@@ -1,5 +1,6 @@
-
\ No newline at end of file
+ android:textSize="@dimen/body_font_size"
+ />
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
index 5df480e..c73e1ba 100644
--- a/app/src/main/res/menu/menu_main.xml
+++ b/app/src/main/res/menu/menu_main.xml
@@ -1,8 +1,6 @@
\ 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..f46e13d 100644
--- a/app/src/main/res/menu/menu_viewsite.xml
+++ b/app/src/main/res/menu/menu_viewsite.xml
@@ -2,16 +2,16 @@
\ No newline at end of file
+
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
new file mode 100644
index 0000000..853469b
--- /dev/null
+++ b/app/src/main/res/values/arrays.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ - Minute(s)
+ - Hour(s)
+ - Day(s)
+ - Week(s)
+
+
+
+ - @string/refresh_status
+ - @string/remove_site
+
+
+
+ - Status Code
+ - Search Term
+ - JavaScript Evaluation
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 8127492..1f6cb0f 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,16 +1,16 @@
- #455A64
- #37474F
- #FF6E40
+ #455A64
+ #37474F
+ #FF6E40
- #EEEEEE
+ #EEEEEE
- #E53935
- #FDD835
- #43A047
+ #E53935
+ #FDD835
+ #43A047
- #37474F
+ #37474F
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 629f464..33f6f97 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -1,23 +1,21 @@
- 24sp
- 20sp
- 16sp
- 14sp
- 12sp
+ 20sp
+ 16sp
+ 14sp
+ 12sp
+ 26sp
- 8dp
- 12dp
- 16dp
- 24dp
- 32dp
+ 8dp
+ 12dp
+ 16dp
+ 24dp
- 42dp
- 4dp
- 4dp
- 8dp
- 52dp
- 300dp
- 14sp
+ 42dp
+ 4dp
+ 4dp
+ 8dp
+ 52dp
+ 14sp
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b6c7d39..bcbd592 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,81 +1,55 @@
- Nock Nock (BETA)
+ Nock Nock (BETA)
- Everything checks out!
- Something\'s wrong! Tap for details.
- Checking status…
- Waiting…
+ No sites added!
- No sites added!
-
- About
- About
+ Nock Nock, a simple app designed by 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.
- 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 URL
+ Check Interval
+ Done
+ Please enter a name!
+ Please enter a URL.
+ Please enter a valid URL.
- 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
+ Already checking sites!
+ Remove Site
+ %1$s from your sites?]]>
+ Remove
+ Save
+ View Site
+ Last Check Result
+ Next Check
+ None (turned off)
+ None
-
+ Refresh Status
+
+
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…
+ var responseObj = JSON.parse(response);\nreturn responseObj.success === true;
+ function validate(response) {
+ }
+ Response Validation Mode
+ Search term…
- 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.
-
-
- - Minute(s)
- - Hour(s)
- - Day(s)
- - Week(s)
-
-
-
- - @string/refresh_status
- - @string/remove_site
-
-
-
- - Status Code
- - Search Term
- - JavaScript Evaluation
-
+ 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.
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 1a75349..62efc4c 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -1,35 +1,35 @@
-
+ - #212121
+ - #727272
+
-
+ - @drawable/divider
+
-
+
-
+
diff --git a/build.gradle b/build.gradle
index 93c3bb9..96d8ff4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,38 +1,30 @@
apply from: './dependencies.gradle'
+apply from: './versionsPlugin.gradle'
buildscript {
- apply from: './dependencies.gradle'
+ apply from: './dependencies.gradle'
- repositories {
- google()
- jcenter()
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:' + versions.gradlePlugin
- classpath "com.diffplug.spotless:spotless-plugin-gradle:" + versions.spotlessPlugin
- }
+ repositories {
+ google()
+ jcenter()
+ }
+ 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
+ }
}
allprojects {
- repositories {
- google()
- jcenter()
- maven { url "https://jitpack.io" }
- }
+ repositories {
+ google()
+ jcenter()
+ maven { url "https://dl.bintray.com/drummer-aidan/maven" }
+ maven { url "https://jitpack.io" }
+ }
- buildscript {
- repositories {
- google()
- }
- }
- apply plugin: "com.diffplug.gradle.spotless"
- spotless {
- java {
- target "**/*.java"
- trimTrailingWhitespace()
- removeUnusedImports()
- googleJavaFormat()
- endWithNewline()
- }
- }
+ tasks.withType(Javadoc).all {
+ enabled = false
+ }
}
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..4ff907d
--- /dev/null
+++ b/data/build.gradle
@@ -0,0 +1,22 @@
+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(':utilities')
+
+ implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
+}
+
+apply from: '../spotless.gradle'
\ No newline at end of file
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/ServerModel.kt b/data/src/main/java/com/afollestad/nocknock/data/ServerModel.kt
new file mode 100644
index 0000000..a0eac69
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/ServerModel.kt
@@ -0,0 +1,77 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.data
+
+import android.content.ContentValues
+import android.database.Cursor
+import com.afollestad.nocknock.data.ServerStatus.OK
+import com.afollestad.nocknock.utilities.ext.timeString
+import java.io.Serializable
+import java.lang.System.currentTimeMillis
+
+/** @author Aidan Follestad (afollestad)*/
+data class ServerModel(
+ var id: Int = 0,
+ val name: String = "Unknown",
+ val url: String = "Unknown",
+ val status: ServerStatus = OK,
+ val checkInterval: Long = 0,
+ val lastCheck: Long = 0,
+ val reason: String? = null,
+ val validationMode: ValidationMode,
+ val validationContent: String? = null
+) : Serializable {
+
+ companion object {
+ const val TABLE_NAME = "server_models"
+ const val COLUMN_ID = "_id"
+ const val COLUMN_NAME = "name"
+ const val COLUMN_URL = "url"
+ const val COLUMN_STATUS = "status"
+ const val COLUMN_CHECK_INTERVAL = "check_interval"
+ const val COLUMN_LAST_CHECK = "last_check"
+ const val COLUMN_REASON = "reason"
+ const val COLUMN_VALIDATION_MODE = "validation_mode"
+ const val COLUMN_VALIDATION_CONTENT = "validation_content"
+
+ const val DEFAULT_SORT_ORDER = "$COLUMN_NAME ASC"
+
+ fun pull(cursor: Cursor): ServerModel {
+ return ServerModel(
+ id = cursor.getInt(cursor.getColumnIndex(COLUMN_ID)),
+ name = cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
+ url = cursor.getString(cursor.getColumnIndex(COLUMN_URL)),
+ status = cursor.getInt(cursor.getColumnIndex(COLUMN_STATUS)).toServerStatus(),
+ checkInterval = cursor.getLong(cursor.getColumnIndex(COLUMN_CHECK_INTERVAL)),
+ lastCheck = cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_CHECK)),
+ reason = cursor.getString(cursor.getColumnIndex(COLUMN_REASON)),
+ validationMode = cursor.getInt(
+ cursor.getColumnIndex(COLUMN_VALIDATION_MODE)
+ ).toValidationMode(),
+ validationContent = cursor.getString(cursor.getColumnIndex(COLUMN_VALIDATION_CONTENT))
+ )
+ }
+ }
+
+ fun intervalText() = if (checkInterval <= 0) {
+ ""
+ } else {
+ val now = currentTimeMillis()
+ val nextCheck = lastCheck + checkInterval
+ (nextCheck - now).timeString()
+ }
+
+ fun toContentValues() = ContentValues().apply {
+ put(COLUMN_NAME, name)
+ put(COLUMN_URL, url)
+ put(COLUMN_STATUS, status.value)
+ put(COLUMN_CHECK_INTERVAL, checkInterval)
+ put(COLUMN_LAST_CHECK, lastCheck)
+ put(COLUMN_REASON, reason)
+ put(COLUMN_VALIDATION_MODE, validationMode.value)
+ put(COLUMN_VALIDATION_CONTENT, validationContent)
+ }
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/ServerStatus.kt b/data/src/main/java/com/afollestad/nocknock/data/ServerStatus.kt
new file mode 100644
index 0000000..e1a5463
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/ServerStatus.kt
@@ -0,0 +1,38 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.data
+
+import com.afollestad.nocknock.data.ServerStatus.CHECKING
+import com.afollestad.nocknock.data.ServerStatus.OK
+import com.afollestad.nocknock.data.ServerStatus.WAITING
+
+/** @author Aidan Follestad (afollestad) */
+enum class ServerStatus(val value: Int) {
+ OK(1),
+ WAITING(2),
+ CHECKING(3),
+ 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 validationMode: $value")
+ }
+ }
+}
+
+fun ServerStatus.textRes() = when (this) {
+ OK -> R.string.everything_checks_out
+ WAITING -> R.string.waiting
+ CHECKING -> R.string.checking_status
+ else -> 0
+}
+
+fun Int.toServerStatus() = ServerStatus.fromValue(this)
diff --git a/data/src/main/java/com/afollestad/nocknock/data/ValidationMode.kt b/data/src/main/java/com/afollestad/nocknock/data/ValidationMode.kt
new file mode 100644
index 0000000..e209931
--- /dev/null
+++ b/data/src/main/java/com/afollestad/nocknock/data/ValidationMode.kt
@@ -0,0 +1,25 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.data
+
+/** @author Aidan Follestad (afollestad) */
+enum class ValidationMode(val value: Int) {
+ STATUS_CODE(1),
+ TERM_SEARCH(2),
+ JAVASCRIPT(3);
+
+ 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 Int.toValidationMode() = ValidationMode.fromValue(this)
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
index 2fa2f94..a6ba6f4 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -1,13 +1,29 @@
ext.versions = [
- minSdk : 21,
- compileSdk : 27,
- buildTools : '27.0.2',
- publishVersion : '0.1.3.1',
- publishVersionCode: 14,
- gradlePlugin : '3.0.1',
- spotlessPlugin : '3.8.0',
- supportLib : '27.0.2',
- materialDialogs : '0.9.6.0',
- bridge : '5.1.2',
- inquiry : '5.0.0'
-]
\ No newline at end of file
+ minSdk : 21,
+ compileSdk : 28,
+ buildTools : '28.0.3',
+ publishVersion : '0.7.1',
+ publishVersionCode: 27,
+
+ gradlePlugin : '3.2.1',
+ spotlessPlugin : '3.16.0',
+ versionPlugin : '0.20.0',
+
+ okHttp : '3.12.0',
+ rhino : '1.7.10',
+
+ dagger : '2.19',
+ kotlin : '1.3.10',
+ coroutines : '1.0.1',
+ androidx : '1.0.0',
+
+ rxBinding : '3.0.0-alpha1',
+
+ materialDialogs : '2.0.0-rc3',
+ rxkPrefs : '1.2.0',
+
+ junit : '4.12',
+ mockito : '2.23.0',
+ mockitoKotlin : '2.0.0-RC1',
+ truth : '0.42'
+]
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..ae51e07
--- /dev/null
+++ b/engine/build.gradle
@@ -0,0 +1,36 @@
+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(':utilities')
+ 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
+
+ api 'com.squareup.okhttp3:okhttp:' + versions.okHttp
+
+ implementation 'com.google.dagger:dagger:' + versions.dagger
+ kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
+
+ 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..77b26cb
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt
@@ -0,0 +1,31 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.engine
+
+import com.afollestad.nocknock.engine.db.RealServerModelStore
+import com.afollestad.nocknock.engine.db.ServerModelStore
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
+import com.afollestad.nocknock.engine.statuscheck.RealCheckStatusManager
+import dagger.Binds
+import dagger.Module
+import javax.inject.Singleton
+
+/** @author Aidan Follestad (afollestad) */
+@Module
+abstract class EngineModule {
+
+ @Binds
+ @Singleton
+ abstract fun provideServerModelStore(
+ serverModelStore: RealServerModelStore
+ ): ServerModelStore
+
+ @Binds
+ @Singleton
+ abstract fun provideCheckStatusManager(
+ checkStatusManager: RealCheckStatusManager
+ ): CheckStatusManager
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelDbHelper.kt b/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelDbHelper.kt
new file mode 100644
index 0000000..7b8bcfc
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelDbHelper.kt
@@ -0,0 +1,54 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.engine.db
+
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteOpenHelper
+import com.afollestad.nocknock.data.ServerModel
+
+private const val SQL_CREATE_ENTRIES =
+ "CREATE TABLE ${ServerModel.TABLE_NAME} (" +
+ "${ServerModel.COLUMN_ID} INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," +
+ "${ServerModel.COLUMN_NAME} TEXT," +
+ "${ServerModel.COLUMN_URL} TEXT," +
+ "${ServerModel.COLUMN_STATUS} INTEGER," +
+ "${ServerModel.COLUMN_CHECK_INTERVAL} INTEGER," +
+ "${ServerModel.COLUMN_LAST_CHECK} INTEGER," +
+ "${ServerModel.COLUMN_REASON} TEXT," +
+ "${ServerModel.COLUMN_VALIDATION_MODE} INTEGER," +
+ "${ServerModel.COLUMN_VALIDATION_CONTENT} TEXT)"
+
+private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${ServerModel.TABLE_NAME}"
+
+/** @author Aidan Follestad (afollestad) */
+class ServerModelDbHelper(context: Context) : SQLiteOpenHelper(
+ context, DATABASE_NAME, null, DATABASE_VERSION
+) {
+ companion object {
+ const val DATABASE_VERSION = 1
+ const val DATABASE_NAME = "ServerModels.db"
+ }
+
+ override fun onCreate(db: SQLiteDatabase) {
+ db.execSQL(SQL_CREATE_ENTRIES)
+ }
+
+ override fun onUpgrade(
+ db: SQLiteDatabase,
+ oldVersion: Int,
+ newVersion: Int
+ ) {
+ db.execSQL(SQL_DELETE_ENTRIES)
+ onCreate(db)
+ }
+
+ override fun onDowngrade(
+ db: SQLiteDatabase,
+ oldVersion: Int,
+ newVersion: Int
+ ) = onUpgrade(db, oldVersion, newVersion)
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelStore.kt b/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelStore.kt
new file mode 100644
index 0000000..5cde4ef
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelStore.kt
@@ -0,0 +1,126 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.engine.db
+
+import android.app.Application
+import android.database.Cursor
+import com.afollestad.nocknock.data.ServerModel
+import com.afollestad.nocknock.data.ServerModel.Companion.COLUMN_ID
+import com.afollestad.nocknock.data.ServerModel.Companion.DEFAULT_SORT_ORDER
+import com.afollestad.nocknock.data.ServerModel.Companion.TABLE_NAME
+import com.afollestad.nocknock.utilities.ext.diffFrom
+import javax.inject.Inject
+
+/** @author Aidan Follestad (afollestad) */
+interface ServerModelStore {
+
+ suspend fun get(id: Int? = null): List
+
+ suspend fun put(model: ServerModel): ServerModel
+
+ suspend fun update(model: ServerModel): Int
+
+ suspend fun delete(model: ServerModel): Int
+
+ suspend fun delete(id: Int): Int
+
+ suspend fun deleteAll(): Int
+}
+
+/** @author Aidan Follestad (afollestad) */
+class RealServerModelStore @Inject constructor(
+ app: Application
+) : ServerModelStore {
+
+ private val dbHelper = ServerModelDbHelper(app)
+
+ override suspend fun get(id: Int?): List {
+ if (id == null) {
+ return getAll()
+ }
+
+ val reader = dbHelper.readableDatabase
+ val selection = "$COLUMN_ID = ?"
+ val selectionArgs = arrayOf("$id")
+ val cursor = reader.query(
+ TABLE_NAME,
+ null,
+ selection,
+ selectionArgs,
+ null,
+ null,
+ DEFAULT_SORT_ORDER,
+ "1"
+ )
+ cursor.use {
+ val results = readModels(it)
+ check(results.size == 1) { "Should only get one model per ID." }
+ return results
+ }
+ }
+
+ private fun getAll(): List {
+ val reader = dbHelper.readableDatabase
+ val cursor = reader.query(
+ TABLE_NAME,
+ null,
+ null,
+ null,
+ null,
+ null,
+ DEFAULT_SORT_ORDER,
+ null
+ )
+ cursor.use { return readModels(it) }
+ }
+
+ override suspend fun put(model: ServerModel): ServerModel {
+ check(model.id == 0) { "Cannot put a model that already has an ID." }
+
+ val writer = dbHelper.writableDatabase
+ val newId = writer.insert(TABLE_NAME, null, model.toContentValues())
+
+ return model.copy(id = newId.toInt())
+ }
+
+ override suspend fun update(model: ServerModel): Int {
+ check(model.id != 0) { "Cannot update a model that does not have an ID." }
+
+ val oldModel = get(model.id).single()
+ val oldValues = oldModel.toContentValues()
+
+ val writer = dbHelper.writableDatabase
+ val newValues = model.toContentValues()
+ val valuesDiff = oldValues.diffFrom(newValues)
+
+ val selection = "$COLUMN_ID = ?"
+ val selectionArgs = arrayOf("${model.id}")
+
+ return writer.update(TABLE_NAME, valuesDiff, selection, selectionArgs)
+ }
+
+ override suspend fun delete(model: ServerModel) = delete(model.id)
+
+ override suspend fun delete(id: Int): Int {
+ check(id != 0) { "Cannot delete a model that doesn't have an ID." }
+
+ val selection = "$COLUMN_ID = ?"
+ val selectionArgs = arrayOf("$id")
+ return dbHelper.writableDatabase.delete(TABLE_NAME, selection, selectionArgs)
+ }
+
+ override suspend fun deleteAll(): Int {
+ return dbHelper.writableDatabase.delete(TABLE_NAME, null, null)
+ }
+
+ private fun readModels(cursor: Cursor): List {
+ val results = mutableListOf()
+ while (cursor.moveToNext()) {
+ results.add(ServerModel.pull(cursor))
+ }
+ return results
+ }
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusJob.kt b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusJob.kt
new file mode 100644
index 0000000..99eca0d
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusJob.kt
@@ -0,0 +1,167 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.engine.statuscheck
+
+import android.app.job.JobParameters
+import android.app.job.JobService
+import android.content.Intent
+import android.util.Log
+import com.afollestad.nocknock.data.ServerModel
+import com.afollestad.nocknock.data.ServerStatus
+import com.afollestad.nocknock.data.ServerStatus.CHECKING
+import com.afollestad.nocknock.data.ServerStatus.ERROR
+import com.afollestad.nocknock.data.ServerStatus.OK
+import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
+import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
+import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
+import com.afollestad.nocknock.engine.BuildConfig.APPLICATION_ID
+import com.afollestad.nocknock.engine.db.ServerModelStore
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.utilities.BuildConfig
+import com.afollestad.nocknock.utilities.ext.injector
+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 java.lang.System.currentTimeMillis
+import javax.inject.Inject
+
+/** @author Aidan Follestad (afollestad)*/
+class CheckStatusJob : 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 fun log(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d("CheckStatusJob", message)
+ }
+ }
+ }
+
+ @Inject lateinit var modelStore: ServerModelStore
+ @Inject lateinit var checkStatusManager: CheckStatusManager
+ @Inject lateinit var notificationManager: NockNotificationManager
+
+ override fun onStartJob(params: JobParameters): Boolean {
+ injector().injectInto(this)
+ val siteId = params.extras.getInt(KEY_SITE_ID)
+
+ GlobalScope.launch(Main) {
+ val sites = async(IO) { modelStore.get(id = siteId) }.await()
+ if (sites.isEmpty()) {
+ log("Unable to find any sites for ID $siteId, this job will not be rescheduled.")
+ return@launch jobFinished(params, false)
+ }
+
+ val site = sites.single()
+ 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 result = async(IO) {
+ updateStatus(site, CHECKING)
+ val checkResult = checkStatusManager.performCheck(site)
+ val resultModel = checkResult.model
+ val resultResponse = checkResult.response
+
+ if (resultModel.status != OK) {
+ log("Got unsuccessful check status back: ${resultModel.reason}")
+ return@async updateStatus(site = resultModel)
+ } else {
+ when (site.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(site.validationContent ?: "")) {
+ updateStatus(
+ resultModel.copy(
+ status = ERROR,
+ reason = "Term \"${site.validationContent}\" not found in response body."
+ )
+ )
+ } else {
+ resultModel
+ }
+ }
+ JAVASCRIPT -> {
+ val body = resultResponse?.body()?.string() ?: ""
+ log("Using JAVASCRIPT validation mode on body of length: ${body.length}")
+ val reason = JavaScript.eval(resultModel.validationContent ?: "", body)
+ return@async if (reason != null) {
+ updateStatus(resultModel.copy(reason = reason), status = ERROR)
+ } else {
+ resultModel
+ }
+ }
+ STATUS_CODE -> {
+ // We already know the status code is successful because we are in this else branch
+ updateStatus(
+ resultModel.copy(
+ status = OK,
+ reason = null
+ )
+ )
+ }
+ else -> {
+ throw IllegalArgumentException("Unknown validation mode: ${site.validationMode}")
+ }
+ }
+ }
+ }.await()
+
+ if (result.status == OK) {
+ notificationManager.cancelStatusNotification(result)
+ } else {
+ notificationManager.postStatusNotification(result)
+ }
+
+ checkStatusManager.scheduleCheck(result)
+ }
+
+ return true
+ }
+
+ override fun onStopJob(params: JobParameters): Boolean {
+ val siteId = params.extras.getInt(KEY_SITE_ID)
+ log("Check job for site $siteId is done")
+ return true
+ }
+
+ private suspend fun updateStatus(
+ site: ServerModel,
+ status: ServerStatus = site.status
+ ): ServerModel {
+ log("Updating ${site.name} (${site.url}) status to $status...")
+
+ val lastCheckTime =
+ if (status == CHECKING) currentTimeMillis()
+ else site.lastCheck
+ val reason =
+ if (status == OK) null
+ else site.reason
+
+ val newSiteModel = site.copy(
+ status = status,
+ lastCheck = lastCheckTime,
+ reason = reason
+ )
+ modelStore.update(newSiteModel)
+
+ withContext(Main) {
+ sendBroadcast(Intent(ACTION_STATUS_UPDATE).apply { putExtra(KEY_UPDATE_MODEL, site) })
+ }
+ return newSiteModel
+ }
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt
new file mode 100644
index 0000000..1411ebe
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt
@@ -0,0 +1,143 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.engine.statuscheck
+
+import android.app.Application
+import android.app.job.JobInfo.NETWORK_TYPE_ANY
+import android.app.job.JobScheduler
+import android.app.job.JobScheduler.RESULT_SUCCESS
+import android.os.PersistableBundle
+import android.util.Log
+import com.afollestad.nocknock.data.ServerModel
+import com.afollestad.nocknock.data.ServerStatus.ERROR
+import com.afollestad.nocknock.data.ServerStatus.OK
+import com.afollestad.nocknock.engine.R
+import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_SITE_ID
+import com.afollestad.nocknock.utilities.BuildConfig
+import com.afollestad.nocknock.utilities.providers.StringProvider
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import java.net.SocketTimeoutException
+import javax.inject.Inject
+
+/** @author Aidan Follestad (afollestad) */
+data class CheckResult(
+ val model: ServerModel,
+ val response: Response? = null
+)
+
+/** @author Aidan Follestad (afollestad) */
+interface CheckStatusManager {
+
+ fun scheduleCheck(
+ site: ServerModel,
+ rightNow: Boolean = false
+ )
+
+ fun cancelCheck(site: ServerModel)
+
+ suspend fun performCheck(site: ServerModel): CheckResult
+}
+
+class RealCheckStatusManager @Inject constructor(
+ private val app: Application,
+ private val jobScheduler: JobScheduler,
+ private val okHttpClient: OkHttpClient,
+ private val stringProvider: StringProvider
+) : CheckStatusManager {
+
+ companion object {
+
+ private fun log(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d("CheckStatusManager", message)
+ }
+ }
+ }
+
+ override fun scheduleCheck(
+ site: ServerModel,
+ rightNow: Boolean
+ ) {
+ check(site.id != 0) { "Cannot schedule checks for jobs with no ID." }
+ log("Requesting a check job for site to be scheduled: $site")
+
+ val extras = PersistableBundle().apply {
+ putInt(KEY_SITE_ID, site.id)
+ }
+
+ // Note that 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 this app.
+ val jobInfo = jobInfo(app, site.id, CheckStatusJob::class.java) {
+ setRequiredNetworkType(NETWORK_TYPE_ANY)
+ if (rightNow) {
+ log(">> Job for site ${site.id} should be executed now")
+ setMinimumLatency(1)
+ } else {
+ log(">> Job for site ${site.id} should be in ${site.checkInterval}ms")
+ setMinimumLatency(site.checkInterval)
+ }
+ setExtras(extras)
+ setPersisted(true)
+ }
+ val dispatchResult = jobScheduler.schedule(jobInfo)
+ if (dispatchResult != RESULT_SUCCESS) {
+ log("Failed to schedule a check job for site: ${site.id}")
+ } else {
+ log("Check job successfully scheduled for site: ${site.id}")
+ }
+ }
+
+ override fun cancelCheck(site: ServerModel) {
+ check(site.id != 0) { "Cannot cancel scheduled checks for jobs with no ID." }
+ log("Cancelling scheduled checks for site: ${site.id}")
+ jobScheduler.cancel(site.id)
+ }
+
+ override suspend fun performCheck(site: ServerModel): CheckResult {
+ check(site.id != 0) { "Cannot schedule checks for jobs with no ID." }
+ log("performCheck(${site.id}) - GET ${site.url}")
+
+ val request = Request.Builder()
+ .url(site.url)
+ .get()
+ .build()
+
+ return try {
+ val response = okHttpClient.newCall(request)
+ .execute()
+ if (response.isSuccessful || response.code() == 401) {
+ log("performCheck(${site.id}) = Successful")
+ CheckResult(
+ model = site.copy(status = OK, reason = null),
+ response = response
+ )
+ } else {
+ log("performCheck(${site.id}) = Failure, HTTP code ${response.code()}")
+ CheckResult(
+ model = site.copy(
+ status = ERROR,
+ reason = "Response ${response.code()} - ${response.body()?.string() ?: "Unknown"}"
+ ),
+ response = response
+ )
+ }
+ } catch (timeoutEx: SocketTimeoutException) {
+ log("performCheck(${site.id}) = Socket Timeout")
+ CheckResult(
+ model = site.copy(
+ status = ERROR,
+ reason = stringProvider.get(R.string.timeout)
+ )
+ )
+ } catch (ex: Exception) {
+ log("performCheck(${site.id}) = Error: ${ex.message}")
+ CheckResult(model = site.copy(status = ERROR, reason = ex.message))
+ }
+ }
+}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/JobInfo.kt b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/JobInfo.kt
new file mode 100644
index 0000000..e13cac2
--- /dev/null
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/JobInfo.kt
@@ -0,0 +1,25 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.engine.statuscheck
+
+import android.app.job.JobInfo
+import android.app.job.JobService
+import android.content.ComponentName
+import android.content.Context
+
+typealias JobInfoBuilder = JobInfo.Builder
+
+fun jobInfo(
+ context: Context,
+ id: Int,
+ target: Class,
+ exec: JobInfoBuilder.() -> JobInfoBuilder
+): JobInfo {
+ val component = ComponentName(context, target)
+ val builder = JobInfo.Builder(id, component)
+ exec(builder)
+ return builder.build()
+}
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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index e2a2454..881a778 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip
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..e3d7838
--- /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(':data')
+ implementation project(':utilities')
+
+ api 'androidx.appcompat:appcompat:' + versions.androidx
+
+ 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 'com.google.dagger:dagger:' + versions.dagger
+ kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
+
+ 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..9dd2e5f
--- /dev/null
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/Channel.kt
@@ -0,0 +1,38 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.notifications
+
+import android.app.NotificationChannel
+import android.content.Context
+import android.os.Build.VERSION_CODES
+import androidx.annotation.RequiresApi
+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
+) {
+ Statuses(
+ id = "statuses",
+ title = R.string.channel_server_status_title,
+ description = R.string.channel_server_status_description,
+ importance = IMPORTANCE_DEFAULT
+ )
+}
+
+/** @author Aidan Follestad (afollestad) */
+@RequiresApi(VERSION_CODES.O)
+fun Channel.toNotificationChannel(context: Context): NotificationChannel {
+ val titleText = context.getString(this.title)
+ val descriptionText = context.getString(this.description)
+ return NotificationChannel(this.id, titleText, this.importance)
+ .apply {
+ description = descriptionText
+ }
+}
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..7159071
--- /dev/null
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt
@@ -0,0 +1,111 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.notifications
+
+import android.app.Application
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_CANCEL_CURRENT
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import androidx.core.app.NotificationCompat.DEFAULT_VIBRATE
+import com.afollestad.nocknock.data.ServerModel
+import com.afollestad.nocknock.notifications.Channel.Statuses
+import com.afollestad.nocknock.utilities.providers.BitmapProvider
+import com.afollestad.nocknock.utilities.providers.StringProvider
+import com.afollestad.nocknock.utilities.qualifiers.AppIconRes
+import com.afollestad.nocknock.utilities.util.hasOreo
+import javax.inject.Inject
+
+const val STATUS_NOTIFICATION_ID = 3456
+
+/** @author Aidan Follestad (@afollestad) */
+interface NockNotificationManager {
+
+ fun setIsAppOpen(open: Boolean)
+
+ fun createChannels()
+
+ fun postStatusNotification(model: ServerModel)
+
+ fun cancelStatusNotification(model: ServerModel)
+
+ fun cancelStatusNotifications()
+}
+
+/** @author Aidan Follestad (afollestad) */
+class RealNockNotificationManager @Inject constructor(
+ private val app: Application,
+ @AppIconRes private val appIconRes: Int,
+ private val stockManager: NotificationManager,
+ private val bitmapProvider: BitmapProvider,
+ private val stringProvider: StringProvider
+) : NockNotificationManager {
+ companion object {
+ private const val BASE_REQUEST_CODE = 44
+
+ const val KEY_MODEL = "model"
+ }
+
+ private var isAppOpen = false
+
+ override fun setIsAppOpen(open: Boolean) {
+ this.isAppOpen = open
+ }
+
+ override fun createChannels() {
+ Channel.values()
+ .forEach(this::createChannel)
+ }
+
+ override fun postStatusNotification(model: ServerModel) {
+ if (isAppOpen) {
+ // Don't show notifications while the app is open
+ return
+ }
+
+ val viewSiteActivityCls =
+ Class.forName("com.afollestad.nocknock.ui.ViewSiteActivity")
+ val openIntent = Intent(app, viewSiteActivityCls).apply {
+ putExtra(KEY_MODEL, model)
+ addFlags(FLAG_ACTIVITY_NEW_TASK)
+ }
+ val openPendingIntent = PendingIntent.getBroadcast(
+ app,
+ BASE_REQUEST_CODE + model.id,
+ openIntent,
+ FLAG_CANCEL_CURRENT
+ )
+
+ val newNotification = notification(app, Statuses) {
+ setContentTitle(model.name)
+ setContentText(stringProvider.get(R.string.something_wrong))
+ setContentIntent(openPendingIntent)
+ setSmallIcon(R.drawable.ic_notification)
+ setLargeIcon(bitmapProvider.get(appIconRes))
+ setAutoCancel(true)
+ setDefaults(DEFAULT_VIBRATE)
+ }
+
+ stockManager.notify(model.url, STATUS_NOTIFICATION_ID, newNotification)
+ }
+
+ override fun cancelStatusNotification(model: ServerModel) {
+ stockManager.cancel(BASE_REQUEST_CODE + model.id)
+ }
+
+ override fun cancelStatusNotifications() {
+ stockManager.cancelAll()
+ }
+
+ private fun createChannel(channel: Channel) {
+ if (!hasOreo()) {
+ return
+ }
+ val notificationChannel = channel.toNotificationChannel(app)
+ stockManager.createNotificationChannel(notificationChannel)
+ }
+}
diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/Notification.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/Notification.kt
new file mode 100644
index 0000000..e7c59d7
--- /dev/null
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/Notification.kt
@@ -0,0 +1,24 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.notifications
+
+import android.app.Notification
+import android.content.Context
+import androidx.core.app.NotificationCompat
+
+typealias NotificationBuilder = NotificationCompat.Builder
+typealias NotificationConstructor = NotificationBuilder.() -> Unit
+
+/** @author Aidan Follestad (afollestad) */
+fun notification(
+ context: Context,
+ channel: Channel,
+ builder: NotificationConstructor
+): Notification {
+ val newNotification = NotificationCompat.Builder(context, channel.id)
+ builder(newNotification)
+ return newNotification.build()
+}
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..160e3b4
--- /dev/null
+++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.kt
@@ -0,0 +1,21 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.notifications
+
+import dagger.Binds
+import dagger.Module
+import javax.inject.Singleton
+
+/** @author Aidan Follestad (afollestad) */
+@Module
+abstract class NotificationsModule {
+
+ @Binds
+ @Singleton
+ abstract fun provideNockNotificationManager(
+ notificationManager: RealNockNotificationManager
+ ): NockNotificationManager
+}
diff --git a/app/src/main/res/drawable-hdpi-v11/ic_notification.png b/notifications/src/main/res/drawable-hdpi-v11/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-hdpi-v11/ic_notification.png
rename to notifications/src/main/res/drawable-hdpi-v11/ic_notification.png
diff --git a/app/src/main/res/drawable-mdpi-v11/ic_notification.png b/notifications/src/main/res/drawable-mdpi-v11/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-mdpi-v11/ic_notification.png
rename to notifications/src/main/res/drawable-mdpi-v11/ic_notification.png
diff --git a/app/src/main/res/drawable-xhdpi-v11/ic_notification.png b/notifications/src/main/res/drawable-xhdpi-v11/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-xhdpi-v11/ic_notification.png
rename to notifications/src/main/res/drawable-xhdpi-v11/ic_notification.png
diff --git a/app/src/main/res/drawable-xxhdpi-v11/ic_notification.png b/notifications/src/main/res/drawable-xxhdpi-v11/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-xxhdpi-v11/ic_notification.png
rename to notifications/src/main/res/drawable-xxhdpi-v11/ic_notification.png
diff --git a/app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png b/notifications/src/main/res/drawable-xxxhdpi-v11/ic_notification.png
similarity index 100%
rename from app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png
rename to notifications/src/main/res/drawable-xxxhdpi-v11/ic_notification.png
diff --git a/notifications/src/main/res/values/strings.xml b/notifications/src/main/res/values/strings.xml
new file mode 100644
index 0000000..8b1e992
--- /dev/null
+++ b/notifications/src/main/res/values/strings.xml
@@ -0,0 +1,11 @@
+
+
+
+ Server Statuses
+
+ Notifications for server status changes, whether it\'s successful statuses or error statuses.
+
+
+ Something\'s wrong! Tap for details.
+
+
diff --git a/notifications/src/test/java/com/afollestad/nocknock/notifications/ExampleUnitTest.java b/notifications/src/test/java/com/afollestad/nocknock/notifications/ExampleUnitTest.java
new file mode 100644
index 0000000..8af7ccc
--- /dev/null
+++ b/notifications/src/test/java/com/afollestad/nocknock/notifications/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package com.afollestad.nocknock.notifications;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() {
+ assertEquals(4, 2 + 2);
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index e7b4def..ea9afdd 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':app'
+include ':app', ':engine', ':notifications', ':data', ':utilities'
diff --git a/spotless.gradle b/spotless.gradle
new file mode 100644
index 0000000..a8b91f4
--- /dev/null
+++ b/spotless.gradle
@@ -0,0 +1,17 @@
+apply plugin: "com.diffplug.gradle.spotless"
+spotless {
+ java {
+ target "**/*.java"
+ trimTrailingWhitespace()
+ removeUnusedImports()
+ googleJavaFormat()
+ endWithNewline()
+ }
+ kotlin {
+ target "**/*.kt"
+ ktlint().userData(['indent_size': '2', 'continuation_indent_size': '2'])
+ licenseHeader '/*\n * Licensed under Apache-2.0\n *\n * Designed and developed by Aidan Follestad (@afollestad)\n */'
+ trimTrailingWhitespace()
+ endWithNewline()
+ }
+}
\ No newline at end of file
diff --git a/utilities/.gitignore b/utilities/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/utilities/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/utilities/build.gradle b/utilities/build.gradle
new file mode 100644
index 0000000..3059ed7
--- /dev/null
+++ b/utilities/build.gradle
@@ -0,0 +1,34 @@
+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 'androidx.annotation:annotation:' + versions.androidx
+
+ 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 'com.google.dagger:dagger:' + versions.dagger
+ kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
+
+ implementation 'org.mozilla:rhino:' + versions.rhino
+
+ 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/utilities/src/main/AndroidManifest.xml b/utilities/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d67cd60
--- /dev/null
+++ b/utilities/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/Injector.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/Injector.kt
new file mode 100644
index 0000000..003b12e
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/Injector.kt
@@ -0,0 +1,12 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities
+
+/** @author Aidan Follestad (afollestad)*/
+interface Injector {
+
+ fun injectInto(target: Any)
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt
new file mode 100644
index 0000000..0881fa4
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt
@@ -0,0 +1,31 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities
+
+import com.afollestad.nocknock.utilities.providers.BitmapProvider
+import com.afollestad.nocknock.utilities.providers.RealBitmapProvider
+import com.afollestad.nocknock.utilities.providers.RealStringProvider
+import com.afollestad.nocknock.utilities.providers.StringProvider
+import dagger.Binds
+import dagger.Module
+import javax.inject.Singleton
+
+/** @author Aidan Follestad (afollestad) */
+@Module
+abstract class UtilitiesModule {
+
+ @Binds
+ @Singleton
+ abstract fun provideBitmapProvider(
+ bitmapProvider: RealBitmapProvider
+ ): BitmapProvider
+
+ @Binds
+ @Singleton
+ abstract fun provideStringProvider(
+ stringProvider: RealStringProvider
+ ): StringProvider
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ActivityExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ActivityExt.kt
new file mode 100644
index 0000000..7653d84
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ActivityExt.kt
@@ -0,0 +1,27 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.IntentFilter
+
+fun Activity.safeRegisterReceiver(
+ broadcastReceiver: BroadcastReceiver,
+ filter: IntentFilter
+) {
+ try {
+ registerReceiver(broadcastReceiver, filter)
+ } catch (_: Exception) {
+ }
+}
+
+fun Activity.safeUnregisterReceiver(broadcastReceiver: BroadcastReceiver) {
+ try {
+ unregisterReceiver(broadcastReceiver)
+ } catch (_: Exception) {
+ }
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/AnimationExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/AnimationExt.kt
new file mode 100644
index 0000000..a337283
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/AnimationExt.kt
@@ -0,0 +1,18 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+
+fun Animator.onEnd(cb: () -> Unit) {
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ super.onAnimationEnd(animation)
+ cb()
+ }
+ })
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContentValuesExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContentValuesExt.kt
new file mode 100644
index 0000000..a8cdead
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContentValuesExt.kt
@@ -0,0 +1,49 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import android.content.ContentValues
+
+/**
+ * Returns a [ContentValues] instance which contains only values that have changed between
+ * the receiver (original) and parameter (new) instances.
+ */
+fun ContentValues.diffFrom(contentValues: ContentValues): ContentValues {
+ val diff = ContentValues()
+ for ((name, oldValue) in this.valueSet()) {
+ val newValue = contentValues.get(name)
+ if (newValue != oldValue) {
+ diff.putAny(name, newValue)
+ }
+ }
+ return diff
+}
+
+/**
+ * Auto casts an [Any] value and uses the appropriate `put` method to store it
+ * in the [ContentValues] instance.
+ */
+fun ContentValues.putAny(
+ name: String,
+ value: Any?
+) {
+ if (value == null) {
+ putNull(name)
+ return
+ }
+ when (value) {
+ is String -> put(name, value)
+ is Byte -> put(name, value)
+ is Short -> put(name, value)
+ is Int -> put(name, value)
+ is Long -> put(name, value)
+ is Float -> put(name, value)
+ is Double -> put(name, value)
+ is Boolean -> put(name, value)
+ is ByteArray -> put(name, value)
+ else -> throw IllegalArgumentException("ContentValues can't hold $value")
+ }
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContextExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContextExt.kt
new file mode 100644
index 0000000..e6ae4ac
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContextExt.kt
@@ -0,0 +1,14 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import android.app.job.JobService
+import android.content.Context
+import com.afollestad.nocknock.utilities.Injector
+
+fun Context.injector() = applicationContext as Injector
+
+fun JobService.injector() = applicationContext as Injector
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt
new file mode 100644
index 0000000..cdf2336
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt
@@ -0,0 +1,27 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import android.view.View
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlin.coroutines.CoroutineContext
+
+fun View.scopeWhileAttached(
+ context: CoroutineContext,
+ exec: CoroutineScope.() -> Unit
+) {
+ val job = Job(context[Job])
+
+ addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(v: View) = Unit
+ override fun onViewDetachedFromWindow(v: View) {
+ job.cancel()
+ }
+ })
+
+ exec(CoroutineScope(context + job))
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt
new file mode 100644
index 0000000..6e41212
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/DateExt.kt
@@ -0,0 +1,15 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+fun Long.formatDate(): String {
+ val df = SimpleDateFormat("MMMM dd, hh:mm:ss a", Locale.getDefault())
+ return df.format(Date(this))
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/TimeExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/TimeExt.kt
new file mode 100644
index 0000000..68dd5a3
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/TimeExt.kt
@@ -0,0 +1,30 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+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 ->
+ "${ceil((this.toFloat() / MINUTE.toFloat()).toDouble()).toInt()}m"
+ else -> "<1m"
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/UriExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/UriExt.kt
new file mode 100644
index 0000000..cac3c06
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/UriExt.kt
@@ -0,0 +1,10 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import android.net.Uri
+
+fun Uri.isHttpOrHttps() = scheme == "http" || scheme == "https"
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
new file mode 100644
index 0000000..a280efc
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
@@ -0,0 +1,61 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.ext
+
+import android.view.View
+import android.view.View.GONE
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.view.ViewTreeObserver
+import android.widget.AdapterView
+import android.widget.Spinner
+import android.widget.TextView
+
+fun View.show() {
+ visibility = VISIBLE
+}
+
+fun View.conceal() {
+ visibility = INVISIBLE
+}
+
+fun View.hide() {
+ visibility = GONE
+}
+
+fun View.showOrHide(show: Boolean) = if (show) show() else hide()
+
+fun TextView.trimmedText() = text.toString().trim()
+
+fun TextView.textAsLong(): Long {
+ val text = trimmedText()
+ return if (text.isEmpty()) 0L else text.toLong()
+}
+
+fun Spinner.onItemSelected(cb: (Int) -> Unit) {
+ onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onNothingSelected(parent: AdapterView<*>?) = Unit
+
+ override fun onItemSelected(
+ parent: AdapterView<*>?,
+ view: View?,
+ position: Int,
+ id: Long
+ ) = cb(position)
+ }
+}
+
+fun View.onLayout(cb: () -> Unit) {
+ if (this.viewTreeObserver.isAlive) {
+ this.viewTreeObserver.addOnGlobalLayoutListener(
+ object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ cb()
+ this@onLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ }
+ })
+ }
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/js/JavaScript.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/js/JavaScript.kt
new file mode 100644
index 0000000..ebaf3c7
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/js/JavaScript.kt
@@ -0,0 +1,82 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.js
+
+import android.util.Log
+import com.afollestad.nocknock.utilities.BuildConfig
+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
+ }
+ }
+
+ log(
+ "Evaluated to $message ($success): $code"
+ )
+ return if (!success) message else null
+ } finally {
+ Context.exit()
+ }
+ } catch (e: EvaluatorException) {
+ return e.message
+ }
+ }
+
+ private fun log(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d("JavaScript", message)
+ }
+ }
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BitmapProvider.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BitmapProvider.kt
new file mode 100644
index 0000000..5c88ae4
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BitmapProvider.kt
@@ -0,0 +1,28 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.providers
+
+import android.app.Application
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory.decodeResource
+import androidx.annotation.DrawableRes
+import javax.inject.Inject
+
+/** @author Aidan Follestad (afollestad) */
+interface BitmapProvider {
+
+ fun get(@DrawableRes res: Int): Bitmap
+}
+
+/** @author Aidan Follestad (afollestad) */
+class RealBitmapProvider @Inject constructor(
+ private val app: Application
+) : BitmapProvider {
+
+ override fun get(res: Int): Bitmap {
+ return decodeResource(app.resources, res)
+ }
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/StringProvider.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/StringProvider.kt
new file mode 100644
index 0000000..0332255
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/StringProvider.kt
@@ -0,0 +1,26 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.providers
+
+import android.app.Application
+import androidx.annotation.StringRes
+import javax.inject.Inject
+
+/** @author Aidan Follestad (@afollestad) */
+interface StringProvider {
+
+ fun get(@StringRes res: Int): String
+}
+
+/** @author Aidan Follestad (afollestad) */
+class RealStringProvider @Inject constructor(
+ private val app: Application
+) : StringProvider {
+
+ override fun get(res: Int): String {
+ return app.resources.getString(res)
+ }
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/qualifiers/AppIconRes.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/qualifiers/AppIconRes.kt
new file mode 100644
index 0000000..368f662
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/qualifiers/AppIconRes.kt
@@ -0,0 +1,14 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.qualifiers
+
+import javax.inject.Qualifier
+import kotlin.annotation.AnnotationRetention.RUNTIME
+
+/** @author Aidan Follestad (afollestad) */
+@Qualifier
+@Retention(RUNTIME)
+annotation class AppIconRes
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/util/MathUtil.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/util/MathUtil.kt
new file mode 100644
index 0000000..7c5a98f
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/util/MathUtil.kt
@@ -0,0 +1,44 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.util
+
+import android.graphics.Path
+import android.view.View
+
+/** @author Aidan Follestad (afollestad) */
+object MathUtil {
+
+ fun bezierCurve(
+ targetView: View,
+ rootView: View
+ ): Path {
+ val fabCenterX = (targetView.x + targetView.measuredWidth / 2).toInt()
+ val fabCenterY = (targetView.y + targetView.measuredHeight / 2).toInt()
+
+ val endCenterX = rootView.measuredWidth / 2 - targetView.measuredWidth / 2
+ val endCenterY = rootView.measuredHeight / 2 - targetView.measuredHeight / 2
+
+ val halfX = (fabCenterX - endCenterX) / 2
+ val halfY = (fabCenterY - endCenterY) / 2
+
+ var controlX = endCenterX + halfX
+ var controlY = endCenterY + halfY
+
+ controlY -= halfY
+ controlX += halfX
+
+ val path = Path()
+ path.moveTo(targetView.x, targetView.y)
+ path.quadTo(
+ controlX.toFloat(),
+ controlY.toFloat(),
+ endCenterX.toFloat(),
+ endCenterY.toFloat()
+ )
+
+ return path
+ }
+}
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/util/SdkUtil.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/util/SdkUtil.kt
new file mode 100644
index 0000000..4078558
--- /dev/null
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/util/SdkUtil.kt
@@ -0,0 +1,11 @@
+/*
+ * Licensed under Apache-2.0
+ *
+ * Designed and developed by Aidan Follestad (@afollestad)
+ */
+package com.afollestad.nocknock.utilities.util
+
+import android.os.Build.VERSION.SDK_INT
+import android.os.Build.VERSION_CODES.O
+
+fun hasOreo() = SDK_INT >= O
diff --git a/utilities/src/main/res/values/strings.xml b/utilities/src/main/res/values/strings.xml
new file mode 100644
index 0000000..ba45d7d
--- /dev/null
+++ b/utilities/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ utilities
+
diff --git a/versionsPlugin.gradle b/versionsPlugin.gradle
new file mode 100644
index 0000000..1062c5f
--- /dev/null
+++ b/versionsPlugin.gradle
@@ -0,0 +1,14 @@
+apply plugin: "com.github.ben-manes.versions"
+
+dependencyUpdates.resolutionStrategy {
+ componentSelection { rules ->
+ rules.all { ComponentSelection selection ->
+ boolean rejected = ['alpha', 'beta', 'rc', 'cr', 'm'].any { qualifier ->
+ selection.candidate.version ==~ /(?i).*[.-]${qualifier}[.\d-]*/
+ }
+ if (rejected) {
+ selection.reject('Not stable')
+ }
+ }
+ }
+}