diff --git a/app/build.gradle b/app/build.gradle
index 0a8d678..5c3703e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -28,6 +28,8 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:' + versions.androidx
implementation 'com.google.android.material:material:' + versions.androidx
+ kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle
+
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
implementation 'com.google.dagger:dagger:' + versions.dagger
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c0b8ee5..38e59d1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,11 +42,11 @@
android:windowSoftInputMode="stateHidden"/>
-
+
diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt
index 492ce0e..777d8a8 100644
--- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt
+++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt
@@ -18,8 +18,8 @@ package com.afollestad.nocknock
import android.app.Application
import com.afollestad.nocknock.di.AppComponent
import com.afollestad.nocknock.di.DaggerAppComponent
-import com.afollestad.nocknock.engine.statuscheck.BootReceiver
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob
+import com.afollestad.nocknock.engine.validation.BootReceiver
+import com.afollestad.nocknock.engine.validation.ValidationJob
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.main.MainActivity
diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt
index a519e5a..4e061c9 100644
--- a/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt
+++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt
@@ -18,6 +18,7 @@ package com.afollestad.nocknock.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil.calculateDiff
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
@@ -95,55 +96,18 @@ class SiteViewHolder constructor(
/** @author Aidan Follestad (@afollestad) */
class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter() {
- private val models = mutableListOf()
+ private var models = mutableListOf()
internal fun performClick(
index: Int,
longClick: Boolean
) = listener.invoke(models[index], longClick)
- fun add(model: Site) {
- models.add(model)
- notifyItemInserted(models.size - 1)
- }
-
- fun update(target: Site) {
- for ((i, model) in models.withIndex()) {
- if (model.id == target.id) {
- update(i, target)
- break
- }
- }
- }
-
- private fun update(
- index: Int,
- model: Site
- ) {
- models[index] = model
- notifyItemChanged(index)
- }
-
- fun remove(index: Int) {
- models.removeAt(index)
- notifyItemRemoved(index)
- }
-
- fun remove(target: Site) {
- for ((i, model) in models.withIndex()) {
- if (model.id == target.id) {
- remove(i)
- break
- }
- }
- }
-
fun set(newModels: List) {
- this.models.clear()
- if (!newModels.isEmpty()) {
- this.models.addAll(newModels)
- }
- notifyDataSetChanged()
+ val formerModels = this.models
+ this.models = newModels.toMutableList()
+ val diffResult = calculateDiff(SiteDiffCallback(formerModels, this.models))
+ diffResult.dispatchUpdatesTo(this)
}
override fun onCreateViewHolder(
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt
similarity index 53%
rename from app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt
rename to app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt
index 7ce7180..2893c0a 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt
+++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt
@@ -13,36 +13,28 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.afollestad.nocknock.ui.viewsite
+package com.afollestad.nocknock.adapter
-import androidx.annotation.StringRes
+import androidx.recyclerview.widget.DiffUtil
import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.utilities.ext.ScopeReceiver
-import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
-interface ViewSiteView {
+class SiteDiffCallback(
+ private val oldItems: List,
+ private val newItems: List
+) : DiffUtil.Callback() {
- fun setLoading()
+ override fun getOldListSize() = oldItems.size
- fun setDoneLoading()
+ override fun getNewListSize() = newItems.size
- fun displayModel(model: Site)
+ override fun areItemsTheSame(
+ oldItemPosition: Int,
+ newItemPosition: Int
+ ) = oldItems[oldItemPosition].id == oldItems[newItemPosition].id
- fun showOrHideUrlSchemeWarning(show: Boolean)
-
- fun showOrHideValidationSearchTerm(show: Boolean)
-
- fun showOrHideScriptInput(show: Boolean)
-
- fun setValidationModeDescription(@StringRes res: Int)
-
- fun setInputErrors(errors: InputErrors)
-
- fun scopeWhileAttached(
- context: CoroutineContext,
- exec: ScopeReceiver
- )
-
- fun finish()
+ override fun areContentsTheSame(
+ oldItemPosition: Int,
+ newItemPosition: Int
+ ) = oldItems[oldItemPosition] == oldItems[newItemPosition]
}
diff --git a/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt
new file mode 100644
index 0000000..9a507d7
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt
@@ -0,0 +1,62 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.broadcasts
+
+import android.app.Application
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
+import androidx.lifecycle.Lifecycle.Event.ON_RESUME
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.OnLifecycleEvent
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL
+
+typealias SiteCallback = (Site) -> Unit
+
+/** @author Aidan Follestad (@afollestad) */
+class StatusUpdateIntentReceiver(
+ private val app: Application,
+ private val callback: SiteCallback
+) : LifecycleObserver {
+
+ internal val intentReceiver = object : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent
+ ) {
+ if (intent.action == ACTION_STATUS_UPDATE) {
+ val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site
+ ?: return
+ callback(model)
+ }
+ }
+ }
+
+ @OnLifecycleEvent(ON_RESUME)
+ fun onResume() {
+ val filter = IntentFilter().apply {
+ addAction(ACTION_STATUS_UPDATE)
+ }
+ app.registerReceiver(intentReceiver, filter)
+ }
+
+ @OnLifecycleEvent(ON_PAUSE)
+ fun onPause() = app.unregisterReceiver(intentReceiver)
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/di/AppComponent.java b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.java
index e2cb0c3..f7a0cdb 100644
--- a/app/src/main/java/com/afollestad/nocknock/di/AppComponent.java
+++ b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.java
@@ -19,9 +19,10 @@ import android.app.Application;
import android.app.NotificationManager;
import android.app.job.JobScheduler;
import com.afollestad.nocknock.NockNockApp;
+import com.afollestad.nocknock.di.viewmodels.ViewModelModule;
import com.afollestad.nocknock.engine.EngineModule;
-import com.afollestad.nocknock.engine.statuscheck.BootReceiver;
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob;
+import com.afollestad.nocknock.engine.validation.BootReceiver;
+import com.afollestad.nocknock.engine.validation.ValidationJob;
import com.afollestad.nocknock.notifications.NotificationsModule;
import com.afollestad.nocknock.ui.addsite.AddSiteActivity;
import com.afollestad.nocknock.ui.main.MainActivity;
@@ -35,7 +36,13 @@ import okhttp3.OkHttpClient;
/** @author Aidan Follestad (@afollestad) */
@Singleton
@Component(
- modules = {MainModule.class, EngineModule.class, NotificationsModule.class, UtilitiesModule.class}
+ modules = {
+ MainModule.class,
+ ViewModelModule.class,
+ EngineModule.class,
+ NotificationsModule.class,
+ UtilitiesModule.class
+ }
)
public interface AppComponent {
@@ -51,8 +58,7 @@ public interface AppComponent {
void inject(BootReceiver bootReceiver);
- @Component.Builder
- interface Builder {
+ @Component.Builder interface Builder {
@BindsInstance
Builder application(Application application);
diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainModule.java b/app/src/main/java/com/afollestad/nocknock/di/MainModule.java
index ed3aaa5..03dcf7c 100644
--- a/app/src/main/java/com/afollestad/nocknock/di/MainModule.java
+++ b/app/src/main/java/com/afollestad/nocknock/di/MainModule.java
@@ -15,24 +15,21 @@
*/
package com.afollestad.nocknock.di;
-import static androidx.room.Room.databaseBuilder;
-
import android.app.Application;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.data.AppDatabase;
-import com.afollestad.nocknock.ui.addsite.AddSitePresenter;
-import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter;
import com.afollestad.nocknock.ui.main.MainActivity;
-import com.afollestad.nocknock.ui.main.MainPresenter;
-import com.afollestad.nocknock.ui.main.RealMainPresenter;
-import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter;
-import com.afollestad.nocknock.ui.viewsite.ViewSitePresenter;
import com.afollestad.nocknock.utilities.qualifiers.AppIconRes;
+import com.afollestad.nocknock.di.qualifiers.IoDispatcher;
import com.afollestad.nocknock.utilities.qualifiers.MainActivityClass;
-import dagger.Binds;
+import com.afollestad.nocknock.di.qualifiers.MainDispatcher;
import dagger.Module;
import dagger.Provides;
import javax.inject.Singleton;
+import kotlinx.coroutines.CoroutineDispatcher;
+import kotlinx.coroutines.Dispatchers;
+
+import static androidx.room.Room.databaseBuilder;
/** @author Aidan Follestad (@afollestad) */
@Module
@@ -40,18 +37,6 @@ abstract class MainModule {
@SuppressWarnings("FieldCanBeLocal")
private static String DATABASE_NAME = "NockNock.db";
- @Binds
- @Singleton
- abstract MainPresenter provideMainPresenter(RealMainPresenter presenter);
-
- @Binds
- @Singleton
- abstract AddSitePresenter provideAddSitePresenter(RealAddSitePresenter presenter);
-
- @Binds
- @Singleton
- abstract ViewSitePresenter provideViewSitePresenter(RealViewSitePresenter presenter);
-
@Provides
@Singleton
@AppIconRes
@@ -71,4 +56,18 @@ abstract class MainModule {
static AppDatabase provideAppDatabase(Application app) {
return databaseBuilder(app, AppDatabase.class, DATABASE_NAME).build();
}
+
+ @Provides
+ @Singleton
+ @MainDispatcher
+ static CoroutineDispatcher provideMainDispatcher() {
+ return Dispatchers.getMain();
+ }
+
+ @Provides
+ @Singleton
+ @IoDispatcher
+ static CoroutineDispatcher provideIoDispatcher() {
+ return Dispatchers.getIO();
+ }
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt b/app/src/main/java/com/afollestad/nocknock/di/qualifiers/IoDispatcher.kt
similarity index 60%
rename from app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt
rename to app/src/main/java/com/afollestad/nocknock/di/qualifiers/IoDispatcher.kt
index a8004d2..6ecc572 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt
+++ b/app/src/main/java/com/afollestad/nocknock/di/qualifiers/IoDispatcher.kt
@@ -13,27 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.afollestad.nocknock.ui.main
+package com.afollestad.nocknock.di.qualifiers
-import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.utilities.ext.ScopeReceiver
-import kotlin.coroutines.CoroutineContext
+import javax.inject.Qualifier
+import kotlin.annotation.AnnotationRetention.RUNTIME
/** @author Aidan Follestad (@afollestad) */
-interface MainView {
-
- fun setLoading()
-
- fun setDoneLoading()
-
- fun setModels(models: List)
-
- fun updateModel(model: Site)
-
- fun onSiteDeleted(model: Site)
-
- fun scopeWhileAttached(
- context: CoroutineContext,
- exec: ScopeReceiver
- )
-}
+@Qualifier
+@Retention(RUNTIME)
+annotation class IoDispatcher
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt b/app/src/main/java/com/afollestad/nocknock/di/qualifiers/MainDispatcher.kt
similarity index 52%
rename from app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt
rename to app/src/main/java/com/afollestad/nocknock/di/qualifiers/MainDispatcher.kt
index f6a6caf..de83ab5 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt
+++ b/app/src/main/java/com/afollestad/nocknock/di/qualifiers/MainDispatcher.kt
@@ -13,33 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.afollestad.nocknock.ui.addsite
+package com.afollestad.nocknock.di.qualifiers
-import androidx.annotation.StringRes
-import com.afollestad.nocknock.utilities.ext.ScopeReceiver
-import kotlin.coroutines.CoroutineContext
+import javax.inject.Qualifier
+import kotlin.annotation.AnnotationRetention.RUNTIME
/** @author Aidan Follestad (@afollestad) */
-interface AddSiteView {
-
- fun setLoading()
-
- fun setDoneLoading()
-
- fun showOrHideUrlSchemeWarning(show: Boolean)
-
- fun showOrHideValidationSearchTerm(show: Boolean)
-
- fun showOrHideScriptInput(show: Boolean)
-
- fun setValidationModeDescription(@StringRes res: Int)
-
- fun setInputErrors(errors: InputErrors)
-
- fun onSiteAdded()
-
- fun scopeWhileAttached(
- context: CoroutineContext,
- exec: ScopeReceiver
- )
-}
+@Qualifier
+@Retention(RUNTIME)
+annotation class MainDispatcher
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt b/app/src/main/java/com/afollestad/nocknock/di/viewmodels/ScopedViewModel.kt
similarity index 54%
rename from utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt
rename to app/src/main/java/com/afollestad/nocknock/di/viewmodels/ScopedViewModel.kt
index 48085c2..19b8fb6 100644
--- a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/CoroutineExt.kt
+++ b/app/src/main/java/com/afollestad/nocknock/di/viewmodels/ScopedViewModel.kt
@@ -13,27 +13,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.afollestad.nocknock.utilities.ext
+package com.afollestad.nocknock.di.viewmodels
-import android.view.View
+import androidx.lifecycle.ViewModel
+import com.afollestad.nocknock.di.qualifiers.MainDispatcher
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
-import kotlin.coroutines.CoroutineContext
+import org.jetbrains.annotations.TestOnly
+import javax.inject.Inject
-typealias ScopeReceiver = CoroutineScope.() -> Unit
+abstract class ScopedViewModel : ViewModel() {
-fun View.scopeWhileAttached(
- context: CoroutineContext,
- exec: ScopeReceiver
-) {
- val job = Job(context[Job])
+ @Inject
+ @MainDispatcher
+ lateinit var mainDispatcher: CoroutineDispatcher
- addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
- override fun onViewAttachedToWindow(v: View) = Unit
- override fun onViewDetachedFromWindow(v: View) {
- job.cancel()
- }
- })
+ private val job = Job()
+ protected val scope = CoroutineScope(job + mainDispatcher)
- exec(CoroutineScope(context + job))
+ override fun onCleared() {
+ super.onCleared()
+ job.cancel()
+ }
+
+ @TestOnly open fun destroy() = job.cancel()
}
diff --git a/app/src/main/java/com/afollestad/nocknock/di/viewmodels/ViewModelFactory.kt b/app/src/main/java/com/afollestad/nocknock/di/viewmodels/ViewModelFactory.kt
new file mode 100644
index 0000000..bd95ff9
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/di/viewmodels/ViewModelFactory.kt
@@ -0,0 +1,48 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.di.viewmodels
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import dagger.MapKey
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+import kotlin.annotation.AnnotationRetention.RUNTIME
+import kotlin.annotation.AnnotationTarget.FUNCTION
+import kotlin.annotation.AnnotationTarget.PROPERTY_GETTER
+import kotlin.annotation.AnnotationTarget.PROPERTY_SETTER
+import kotlin.reflect.KClass
+
+typealias ViewModelMap = MutableMap, Provider>
+
+@Target(FUNCTION, PROPERTY_GETTER, PROPERTY_SETTER)
+@Retention(RUNTIME)
+@MapKey
+annotation class ViewModelKey(val value: KClass)
+
+/**
+ * https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455
+ */
+@Singleton
+class ViewModelFactory @Inject constructor(private val viewModels: ViewModelMap) :
+ ViewModelProvider.Factory {
+
+ override fun create(modelClass: Class): T {
+ @Suppress("UNCHECKED_CAST")
+ return viewModels[modelClass]?.get() as T
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/di/viewmodels/ViewModelModule.java b/app/src/main/java/com/afollestad/nocknock/di/viewmodels/ViewModelModule.java
new file mode 100644
index 0000000..d659c78
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/di/viewmodels/ViewModelModule.java
@@ -0,0 +1,33 @@
+package com.afollestad.nocknock.di.viewmodels;
+
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+import com.afollestad.nocknock.ui.addsite.AddSiteViewModel;
+import com.afollestad.nocknock.ui.main.MainViewModel;
+import com.afollestad.nocknock.ui.viewsite.ViewSiteViewModel;
+import dagger.Binds;
+import dagger.Module;
+import dagger.multibindings.IntoMap;
+
+/** https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 */
+@Module
+public abstract class ViewModelModule {
+
+ @Binds
+ abstract ViewModelProvider.Factory bindViewModelFactory(ViewModelFactory factory);
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(MainViewModel.class)
+ abstract ViewModel mainViewModel(MainViewModel viewModel);
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(AddSiteViewModel.class)
+ abstract ViewModel addSiteViewModel(AddSiteViewModel viewModel);
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(ViewSiteViewModel.class)
+ abstract ViewModel viewSiteViewModel(ViewSiteViewModel viewModel);
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt
index 48d0671..93cfc32 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt
@@ -19,21 +19,17 @@ import android.annotation.SuppressLint
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.ViewModelProviders
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.ValidationMode
-import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
-import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
-import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
-import com.afollestad.nocknock.data.model.indexToValidationMode
-import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector
-import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
+import com.afollestad.nocknock.viewcomponents.ext.attachLiveData
import com.afollestad.nocknock.viewcomponents.ext.conceal
-import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onLayout
-import com.afollestad.nocknock.viewcomponents.ext.showOrHide
-import com.afollestad.nocknock.viewcomponents.ext.textAsInt
-import com.afollestad.nocknock.viewcomponents.ext.trimmedText
+import com.afollestad.nocknock.viewcomponents.ext.toViewError
+import com.afollestad.nocknock.viewcomponents.ext.toViewText
+import com.afollestad.nocknock.viewcomponents.ext.toViewVisibility
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
import kotlinx.android.synthetic.main.activity_addsite.inputName
@@ -48,7 +44,6 @@ import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.toolbar
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
import javax.inject.Inject
-import kotlin.coroutines.CoroutineContext
import kotlin.math.max
import kotlin.properties.Delegates.notNull
@@ -57,14 +52,19 @@ const val KEY_FAB_Y = "fab_y"
const val KEY_FAB_SIZE = "fab_size"
/** @author Aidan Follestad (@afollestad) */
-class AddSiteActivity : AppCompatActivity(), AddSiteView {
+class AddSiteActivity : AppCompatActivity() {
- var isClosing: Boolean = false
var revealCx by notNull()
var revealCy by notNull()
var revealRadius by notNull()
- @Inject lateinit var presenter: AddSitePresenter
+ @Inject lateinit var viewModelFactory: ViewModelProvider.Factory
+
+ internal var isClosing = false
+ private val viewModel by lazy {
+ return@lazy ViewModelProviders.of(this, viewModelFactory)
+ .get(AddSiteViewModel::class.java)
+ }
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
@@ -72,8 +72,70 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView {
injector().injectInto(this)
setContentView(R.layout.activity_addsite)
- presenter.takeView(this)
+ setupUi(savedInstanceState)
+ lifecycle.addObserver(viewModel)
+
+ // Loading
+ loadingProgress.observe(this, viewModel.onIsLoading())
+
+ // Name
+ inputName.attachLiveData(this, viewModel.name)
+ viewModel.onNameError()
+ .toViewError(this, inputName)
+
+ // Url
+ inputUrl.attachLiveData(this, viewModel.url)
+ viewModel.onUrlError()
+ .toViewError(this, inputUrl)
+ viewModel.onUrlWarningVisibility()
+ .toViewVisibility(this, textUrlWarning)
+
+ // Timeout
+ responseTimeoutInput.attachLiveData(this, viewModel.timeout)
+ viewModel.onTimeoutError()
+ .toViewError(this, responseTimeoutInput)
+
+ // Validation mode
+ responseValidationMode.attachLiveData(this, viewModel.validationMode,
+ { ValidationMode.fromIndex(it) },
+ { it.toIndex() }
+ )
+ viewModel.onValidationSearchTermError()
+ .toViewError(this, responseValidationSearchTerm)
+ viewModel.onValidationModeDescription()
+ .toViewText(this, validationModeDescription)
+
+ // Validation search term
+ responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
+ viewModel.onValidationSearchTermVisibility()
+ .toViewVisibility(this, responseValidationSearchTerm)
+
+ // Validation script
+ scriptInputLayout.attach(
+ codeData = viewModel.validationScript,
+ errorData = viewModel.onValidationScriptError(),
+ visibility = viewModel.onValidationScriptVisibility()
+ )
+
+ // Check interval
+ checkIntervalLayout.attach(
+ valueData = viewModel.checkIntervalValue,
+ multiplierData = viewModel.checkIntervalUnit,
+ errorData = viewModel.onCheckIntervalError()
+ )
+
+ // Done button
+ doneBtn.setOnClickListener {
+ viewModel.commit {
+ setResult(RESULT_OK)
+ finish()
+ overridePendingTransition(R.anim.fade_out, R.anim.fade_out)
+ }
+ }
+ }
+
+ private fun setupUi(savedInstanceState: Bundle?) {
toolbar.setNavigationOnClickListener { closeActivityWithReveal() }
if (savedInstanceState == null) {
@@ -93,115 +155,14 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView {
}
}
- inputUrl.setOnFocusChangeListener { _, hasFocus ->
- presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
- }
-
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(presenter::onValidationModeSelected)
-
- doneBtn.setOnClickListener {
- val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
- val validationMode =
- responseValidationMode.selectedItemPosition.indexToValidationMode()
- val defaultTimeout = getString(R.string.response_timeout_default).toInt()
-
- isClosing = true
- presenter.commit(
- name = inputName.trimmedText(),
- url = inputUrl.trimmedText(),
- checkInterval = checkInterval,
- validationMode = validationMode,
- validationArgs = validationMode.validationContent(),
- networkTimeout = responseTimeoutInput.textAsInt(defaultValue = defaultTimeout)
- )
- }
}
- override fun onDestroy() {
- presenter.dropView()
- super.onDestroy()
- }
-
- override fun setLoading() = loadingProgress.setLoading()
-
- override fun setDoneLoading() = loadingProgress.setDone()
-
- override fun showOrHideUrlSchemeWarning(show: Boolean) {
- textUrlWarning.showOrHide(show)
- if (show) {
- textUrlWarning.setText(R.string.warning_http_url)
- }
- }
-
- override fun showOrHideValidationSearchTerm(show: Boolean) =
- responseValidationSearchTerm.showOrHide(show)
-
- override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show)
-
- override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res)
-
- override fun setInputErrors(errors: InputErrors) {
- isClosing = false
- inputName.error = if (errors.name != null) {
- getString(errors.name!!)
- } else {
- null
- }
- inputUrl.error = if (errors.url != null) {
- getString(errors.url!!)
- } else {
- null
- }
- checkIntervalLayout.setError(
- if (errors.checkInterval != null) {
- getString(errors.checkInterval!!)
- } else {
- null
- }
- )
- responseValidationSearchTerm.error = if (errors.termSearch != null) {
- getString(errors.termSearch!!)
- } else {
- null
- }
- scriptInputLayout.setError(
- if (errors.javaScript != null) {
- getString(errors.javaScript!!)
- } else {
- null
- }
- )
- responseTimeoutInput.error = if (errors.networkTimeout != null) {
- getString(errors.networkTimeout!!)
- } else {
- null
- }
- }
-
- override fun onSiteAdded() {
- setResult(RESULT_OK)
- finish()
- overridePendingTransition(R.anim.fade_out, R.anim.fade_out)
- }
-
- override fun scopeWhileAttached(
- context: CoroutineContext,
- exec: ScopeReceiver
- ) = rootView.scopeWhileAttached(context, exec)
-
override fun onBackPressed() = closeActivityWithReveal()
-
- private fun ValidationMode.validationContent() = when (this) {
- STATUS_CODE -> null
- TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
- JAVASCRIPT -> scriptInputLayout.getCode()
- }
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt
index 4def223..4f1f9e6 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt
@@ -15,7 +15,7 @@
*/
package com.afollestad.nocknock.ui.addsite
-import android.view.ViewAnimationUtils
+import android.view.ViewAnimationUtils.createCircularReveal
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import com.afollestad.nocknock.utilities.ext.onEnd
@@ -27,7 +27,7 @@ const val REVEAL_DURATION = 300L
internal fun AddSiteActivity.circularRevealActivity() {
val circularReveal =
- ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius)
+ createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius)
.apply {
duration = REVEAL_DURATION
interpolator = DecelerateInterpolator()
@@ -37,9 +37,11 @@ internal fun AddSiteActivity.circularRevealActivity() {
}
internal fun AddSiteActivity.closeActivityWithReveal() {
- if (isClosing) return
+ if (isClosing) {
+ return
+ }
isClosing = true
- ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, revealRadius, 0f)
+ createCircularReveal(rootView, revealCx, revealCy, revealRadius, 0f)
.apply {
duration = REVEAL_DURATION
interpolator = AccelerateInterpolator()
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt
deleted file mode 100644
index 45fc9f3..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt
+++ /dev/null
@@ -1,190 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.afollestad.nocknock.ui.addsite
-
-import androidx.annotation.CheckResult
-import com.afollestad.nocknock.R
-import com.afollestad.nocknock.data.AppDatabase
-import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.data.model.SiteSettings
-import com.afollestad.nocknock.data.model.ValidationMode
-import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
-import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
-import com.afollestad.nocknock.data.putSite
-import com.afollestad.nocknock.engine.statuscheck.ValidationManager
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Dispatchers.Main
-import kotlinx.coroutines.async
-import kotlinx.coroutines.launch
-import okhttp3.HttpUrl
-import javax.inject.Inject
-
-/** @author Aidan Follestad (@afollestad) */
-data class InputErrors(
- var name: Int? = null,
- var url: Int? = null,
- var checkInterval: Int? = null,
- var termSearch: Int? = null,
- var javaScript: Int? = null,
- var networkTimeout: Int? = null
-) {
- @CheckResult fun any(): Boolean {
- return name != null || url != null || checkInterval != null ||
- termSearch != null || javaScript != null || networkTimeout != null
- }
-}
-
-/** @author Aidan Follestad (@afollestad) */
-interface AddSitePresenter {
-
- fun takeView(view: AddSiteView)
-
- fun onUrlInputFocusChange(
- focused: Boolean,
- content: String
- )
-
- fun onValidationModeSelected(index: Int)
-
- fun commit(
- name: String,
- url: String,
- checkInterval: Long,
- validationMode: ValidationMode,
- validationArgs: String?,
- networkTimeout: Int
- )
-
- fun dropView()
-}
-
-/** @author Aidan Follestad (@afollestad) */
-class RealAddSitePresenter @Inject constructor(
- private val database: AppDatabase,
- private val checkStatusManager: ValidationManager
-) : AddSitePresenter {
-
- private var view: AddSiteView? = null
-
- override fun takeView(view: AddSiteView) {
- this.view = view
- }
-
- override fun onUrlInputFocusChange(
- focused: Boolean,
- content: String
- ) {
- if (content.isEmpty() || focused) {
- return
- }
- val url = HttpUrl.parse(content)
- if (url == null ||
- (url.scheme() != "http" &&
- url.scheme() != "https")
- ) {
- view?.showOrHideUrlSchemeWarning(true)
- } else {
- view?.showOrHideUrlSchemeWarning(false)
- }
- }
-
- override fun onValidationModeSelected(index: Int) = with(view!!) {
- showOrHideValidationSearchTerm(index == 1)
- showOrHideScriptInput(index == 2)
- setValidationModeDescription(
- when (index) {
- 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: $index")
- }
- )
- }
-
- override fun commit(
- name: String,
- url: String,
- checkInterval: Long,
- validationMode: ValidationMode,
- validationArgs: String?,
- networkTimeout: Int
- ) {
- val inputErrors = InputErrors()
-
- if (name.isEmpty()) {
- inputErrors.name = R.string.please_enter_name
- }
- if (url.isEmpty()) {
- inputErrors.url = R.string.please_enter_url
- } else if (HttpUrl.parse(url) == null) {
- inputErrors.url = R.string.please_enter_valid_url
- }
- if (checkInterval <= 0) {
- inputErrors.checkInterval = R.string.please_enter_check_interval
- }
- if (validationMode == TERM_SEARCH && validationArgs.isNullOrEmpty()) {
- inputErrors.termSearch = R.string.please_enter_search_term
- } else if (validationMode == JAVASCRIPT && validationArgs.isNullOrEmpty()) {
- inputErrors.javaScript = R.string.please_enter_javaScript
- }
- if (networkTimeout <= 0) {
- inputErrors.networkTimeout = R.string.please_enter_networkTimeout
- }
-
- if (inputErrors.any()) {
- view?.setInputErrors(inputErrors)
- return
- }
-
- val newSettings = SiteSettings(
- validationIntervalMs = checkInterval,
- validationMode = validationMode,
- validationArgs = validationArgs,
- networkTimeout = networkTimeout,
- disabled = false
- )
- val newModel = Site(
- id = 0,
- name = name,
- url = url,
- settings = newSettings,
- lastResult = null
- )
-
- with(view!!) {
- scopeWhileAttached(Main) {
- launch(coroutineContext) {
- setLoading()
- val storedModel = async(IO) {
- database.putSite(newModel)
- }.await()
-
- checkStatusManager.scheduleCheck(
- site = storedModel,
- rightNow = true,
- cancelPrevious = true
- )
- setDoneLoading()
- onSiteAdded()
- }
- }
- }
- }
-
- override fun dropView() {
- view = null
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt
new file mode 100644
index 0000000..e959e8c
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt
@@ -0,0 +1,224 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.addsite
+
+import androidx.annotation.CheckResult
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.AppDatabase
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.SiteSettings
+import com.afollestad.nocknock.data.model.ValidationMode
+import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
+import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
+import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
+import com.afollestad.nocknock.data.putSite
+import com.afollestad.nocknock.di.viewmodels.ScopedViewModel
+import com.afollestad.nocknock.engine.validation.ValidationManager
+import com.afollestad.nocknock.di.qualifiers.IoDispatcher
+import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
+import com.afollestad.nocknock.viewcomponents.ext.map
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import javax.inject.Inject
+
+/** @author Aidan Follestad (@afollestad) */
+class AddSiteViewModel @Inject constructor(
+ private val database: AppDatabase,
+ private val validationManager: ValidationManager,
+ @field:IoDispatcher private val ioDispatcher: CoroutineDispatcher
+) : ScopedViewModel(), LifecycleObserver {
+
+ // Public properties
+ val name = MutableLiveData()
+ val url = MutableLiveData()
+ val timeout = MutableLiveData()
+ val validationMode = MutableLiveData()
+ val validationSearchTerm = MutableLiveData()
+ val validationScript = MutableLiveData()
+ val checkIntervalValue = MutableLiveData()
+ val checkIntervalUnit = MutableLiveData()
+
+ // Private properties
+ private val isLoading = MutableLiveData()
+ private val nameError = MutableLiveData()
+ private val urlError = MutableLiveData()
+ private val timeoutError = MutableLiveData()
+ private val validationSearchTermError = MutableLiveData()
+ private val validationScriptError = MutableLiveData()
+ private val checkIntervalValueError = MutableLiveData()
+
+ // Expose private properties or calculated properties
+ @CheckResult fun onIsLoading(): LiveData = isLoading
+
+ @CheckResult fun onNameError(): LiveData = nameError
+
+ @CheckResult fun onUrlError(): LiveData = urlError
+
+ @CheckResult fun onUrlWarningVisibility(): LiveData {
+ return url.map {
+ val parsed = HttpUrl.parse(it)
+ return@map it.isNotEmpty() &&
+ parsed != null &&
+ parsed.scheme() != "http" &&
+ parsed.scheme() != "https"
+ }
+ }
+
+ @CheckResult fun onTimeoutError(): LiveData = timeoutError
+
+ @CheckResult fun onValidationModeDescription(): LiveData {
+ return validationMode.map {
+ when (it) {
+ STATUS_CODE -> R.string.validation_mode_status_desc
+ TERM_SEARCH -> R.string.validation_mode_term_desc
+ JAVASCRIPT -> R.string.validation_mode_javascript_desc
+ else -> throw IllegalStateException("Unknown validation mode: $it")
+ }
+ }
+ }
+
+ @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError
+
+ @CheckResult fun onValidationSearchTermVisibility() =
+ validationMode.map { it == TERM_SEARCH }
+
+ @CheckResult fun onValidationScriptError(): LiveData = validationScriptError
+
+ @CheckResult fun onValidationScriptVisibility() =
+ validationMode.map { it == JAVASCRIPT }
+
+ @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError
+
+ // Actions
+ fun commit(done: () -> Unit) {
+ scope.launch {
+ val newModel = generateDbModel() ?: return@launch
+ isLoading.value = true
+
+ val storedModel = withContext(ioDispatcher) {
+ database.putSite(newModel)
+ }
+ validationManager.scheduleCheck(
+ site = storedModel,
+ rightNow = true,
+ cancelPrevious = true
+ )
+
+ isLoading.value = false
+ done()
+ }
+ }
+
+ // Utilities
+ private fun getCheckIntervalMs(): Long {
+ val value = checkIntervalValue.value ?: return 0
+ val unit = checkIntervalUnit.value ?: return 0
+ return value * unit
+ }
+
+ private fun getValidationArgs(): String? {
+ return when (validationMode.value) {
+ TERM_SEARCH -> validationSearchTerm.value
+ JAVASCRIPT -> validationScript.value
+ else -> null
+ }
+ }
+
+ private fun generateDbModel(): Site? {
+ var errorCount = 0
+
+ // Validation name
+ if (name.value.isNullOrEmpty()) {
+ nameError.value = R.string.please_enter_name
+ errorCount++
+ } else {
+ nameError.value = null
+ }
+
+ // Validate URL
+ when {
+ url.value.isNullOrEmpty() -> {
+ urlError.value = R.string.please_enter_url
+ errorCount++
+ }
+ HttpUrl.parse(url.value!!) == null -> {
+ urlError.value = R.string.please_enter_valid_url
+ errorCount++
+ }
+ else -> {
+ urlError.value = null
+ }
+ }
+
+ // Validate timeout
+ if (timeout.value.isNullOrLessThan(1)) {
+ timeoutError.value = R.string.please_enter_networkTimeout
+ errorCount++
+ } else {
+ timeoutError.value = null
+ }
+
+ // Validate check interval
+ if (checkIntervalValue.value.isNullOrLessThan(1)) {
+ checkIntervalValueError.value = R.string.please_enter_check_interval
+ errorCount++
+ } else {
+ checkIntervalValueError.value = null
+ }
+
+ // Validate arguments
+ if (validationMode == TERM_SEARCH &&
+ validationSearchTerm.value.isNullOrEmpty()
+ ) {
+ errorCount++
+ validationSearchTermError.value = R.string.please_enter_search_term
+ validationScriptError.value = null
+ } else if (validationMode == JAVASCRIPT &&
+ validationScript.value.isNullOrEmpty()
+ ) {
+ errorCount++
+ validationSearchTermError.value = null
+ validationScriptError.value = R.string.please_enter_javaScript
+ } else {
+ validationSearchTermError.value = null
+ validationScriptError.value = null
+ }
+
+ if (errorCount > 0) {
+ return null
+ }
+
+ val newSettings = SiteSettings(
+ validationIntervalMs = getCheckIntervalMs(),
+ validationMode = validationMode.value!!,
+ validationArgs = getValidationArgs(),
+ networkTimeout = timeout.value!!,
+ disabled = false
+ )
+ return Site(
+ id = 0,
+ name = name.value!!,
+ url = url.value!!,
+ settings = newSettings,
+ lastResult = null
+ )
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt
index 3ff7999..9be4e52 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt
@@ -15,12 +15,12 @@
*/
package com.afollestad.nocknock.ui.main
-import android.content.BroadcastReceiver
-import android.content.Context
import android.content.Intent
-import android.content.IntentFilter
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager
@@ -30,43 +30,58 @@ import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.ServerAdapter
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.dialogs.AboutDialog
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
-import com.afollestad.nocknock.utilities.ext.ScopeReceiver
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.utilities.ext.injector
-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.viewcomponents.ext.showOrHide
import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list
import kotlinx.android.synthetic.main.activity_main.loadingProgress
-import kotlinx.android.synthetic.main.activity_main.rootView
import kotlinx.android.synthetic.main.activity_main.toolbar
import kotlinx.android.synthetic.main.include_empty_view.emptyText
import javax.inject.Inject
-import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
-class MainActivity : AppCompatActivity(), MainView {
+class MainActivity : AppCompatActivity() {
- private val intentReceiver = object : BroadcastReceiver() {
- override fun onReceive(
- context: Context,
- intent: Intent
- ) = presenter.onBroadcast(intent)
- }
-
- @Inject lateinit var presenter: MainPresenter
+ @Inject lateinit var notificationManager: NockNotificationManager
+ @Inject lateinit var viewModelFactory: ViewModelProvider.Factory
private lateinit var adapter: ServerAdapter
+ internal val viewModel by lazy {
+ return@lazy ViewModelProviders.of(this, viewModelFactory)
+ .get(MainViewModel::class.java)
+ }
+ private val statusUpdateReceiver =
+ StatusUpdateIntentReceiver(application) {
+ viewModel.postSiteUpdate(it)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
injector().injectInto(this)
setContentView(R.layout.activity_main)
- presenter.takeView(this)
+ setupUi()
+ notificationManager.createChannels()
+
+ lifecycle.run {
+ addObserver(viewModel)
+ addObserver(statusUpdateReceiver)
+ }
+
+ viewModel.onSites()
+ .observe(this, Observer {
+ adapter.set(it)
+ emptyText.showOrHide(it.isEmpty())
+ })
+ loadingProgress.observe(this, viewModel.onIsLoading())
+
+ processIntent(intent)
+ }
+
+ private fun setupUi() {
toolbar.inflateMenu(R.menu.menu_main)
toolbar.setOnMenuItemClickListener { item ->
if (item.itemId == R.id.about) {
@@ -82,8 +97,6 @@ class MainActivity : AppCompatActivity(), MainView {
list.addItemDecoration(DividerItemDecoration(this, VERTICAL))
fab.setOnClickListener { addSite() }
-
- processIntent(intent)
}
override fun onNewIntent(intent: Intent?) {
@@ -91,52 +104,6 @@ class MainActivity : AppCompatActivity(), MainView {
intent?.let(::processIntent)
}
- override fun onResume() {
- super.onResume()
- val filter = IntentFilter().apply {
- addAction(ACTION_STATUS_UPDATE)
- }
- safeRegisterReceiver(intentReceiver, filter)
- presenter.resume()
- }
-
- override fun onPause() {
- super.onPause()
- safeUnregisterReceiver(intentReceiver)
- }
-
- override fun onDestroy() {
- presenter.dropView()
- super.onDestroy()
- }
-
- override fun setLoading() = loadingProgress.setLoading()
-
- override fun setDoneLoading() = loadingProgress.setDone()
-
- override fun setModels(models: List) {
- list.post {
- adapter.set(models)
- emptyText.showOrHide(models.isEmpty())
- }
- }
-
- override fun updateModel(model: Site) {
- list.post { adapter.update(model) }
- }
-
- override fun onSiteDeleted(model: Site) {
- list.post {
- adapter.remove(model)
- emptyText.showOrHide(adapter.itemCount == 0)
- }
- }
-
- override fun scopeWhileAttached(
- context: CoroutineContext,
- exec: ScopeReceiver
- ) = rootView.scopeWhileAttached(context, exec)
-
private fun onSiteSelected(
model: Site,
longClick: Boolean
@@ -146,7 +113,7 @@ class MainActivity : AppCompatActivity(), MainView {
title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
- 0 -> presenter.refreshSite(model)
+ 0 -> viewModel.refreshSite(model)
1 -> maybeRemoveSite(model)
}
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt
index acfbfd4..63f1c23 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt
@@ -24,7 +24,7 @@ import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.addsite.KEY_FAB_SIZE
import com.afollestad.nocknock.ui.addsite.KEY_FAB_X
import com.afollestad.nocknock.ui.addsite.KEY_FAB_Y
-import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL
+import com.afollestad.nocknock.ui.viewsite.KEY_SITE
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL
import kotlinx.android.synthetic.main.activity_main.fab
@@ -53,14 +53,14 @@ internal fun MainActivity.viewSite(model: Site) {
private fun MainActivity.intentToView(model: Site) =
Intent(this, ViewSiteActivity::class.java).apply {
- putExtra(KEY_VIEW_MODEL, model)
+ putExtra(KEY_SITE, model)
}
internal fun MainActivity.maybeRemoveSite(model: Site) {
MaterialDialog(this).show {
title(R.string.remove_site)
message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
- positiveButton(R.string.remove) { presenter.removeSite(model) }
+ positiveButton(R.string.remove) { viewModel.removeSite(model) }
negativeButton(android.R.string.cancel)
}
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt
deleted file mode 100644
index efd9c64..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.afollestad.nocknock.ui.main
-
-import android.app.Application
-import android.content.Context.MODE_PRIVATE
-import android.content.Intent
-import com.afollestad.nocknock.data.AppDatabase
-import com.afollestad.nocknock.data.allSites
-import com.afollestad.nocknock.data.deleteSite
-import com.afollestad.nocknock.data.legacy.DbMigrator
-import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_UPDATE_MODEL
-import com.afollestad.nocknock.engine.statuscheck.ValidationManager
-import com.afollestad.nocknock.notifications.NockNotificationManager
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Dispatchers.Main
-import kotlinx.coroutines.async
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-import timber.log.Timber.d as log
-
-/** @author Aidan Follestad (@afollestad) */
-interface MainPresenter {
-
- fun takeView(view: MainView)
-
- fun onBroadcast(intent: Intent)
-
- fun resume()
-
- fun refreshSite(site: Site)
-
- fun removeSite(site: Site)
-
- fun dropView()
-}
-
-/** @author Aidan Follestad (@afollestad) */
-class RealMainPresenter @Inject constructor(
- private val app: Application,
- private val database: AppDatabase,
- private val notificationManager: NockNotificationManager,
- private val checkStatusManager: ValidationManager
-) : MainPresenter {
-
- private var view: MainView? = null
-
- override fun takeView(view: MainView) {
- this.view = view
- notificationManager.createChannels()
- ensureCheckJobs()
- }
-
- override fun onBroadcast(intent: Intent) {
- if (intent.action == ACTION_STATUS_UPDATE) {
- val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site
- ?: return
- view?.updateModel(model)
- }
- }
-
- override fun resume() {
- notificationManager.cancelStatusNotifications()
-
- view!!.run {
- setModels(listOf())
- setLoading()
-
- scopeWhileAttached(Main) {
- launch(coroutineContext) {
- doMigrationIfNeeded()
-
- val models = async(IO) { database.allSites() }.await()
-
- setModels(models)
- setDoneLoading()
- }
- }
- }
- }
-
- override fun refreshSite(site: Site) =
- checkStatusManager.scheduleCheck(
- site = site,
- rightNow = true,
- cancelPrevious = true
- )
-
- override fun removeSite(site: Site) {
- checkStatusManager.cancelCheck(site)
- notificationManager.cancelStatusNotification(site)
-
- view!!.scopeWhileAttached(Main) {
- launch(coroutineContext) {
- async(IO) { database.deleteSite(site) }.await()
- view?.onSiteDeleted(site)
- }
- }
- }
-
- override fun dropView() {
- view = null
- }
-
- private suspend fun CoroutineScope.doMigrationIfNeeded() {
- if (needDbMigration()) {
- log("Doing database migration...")
- val migratedCount = async(IO) {
- DbMigrator(app, database).migrateAll()
- }.await()
- didDbMigration()
- log("Database migration done! Migrated $migratedCount models.")
- ensureCheckJobs()
- }
- }
-
- private fun needDbMigration(): Boolean =
- !app.getSharedPreferences("settings", MODE_PRIVATE)
- .getBoolean("did_db_migration", false)
-
- private fun didDbMigration() =
- app.getSharedPreferences("settings", MODE_PRIVATE)
- .edit()
- .putBoolean("did_db_migration", true)
- .apply()
-
- private fun ensureCheckJobs() {
- view!!.scopeWhileAttached(IO) {
- launch(coroutineContext) {
- checkStatusManager.ensureScheduledChecks()
- }
- }
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt
new file mode 100644
index 0000000..1b287b6
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt
@@ -0,0 +1,113 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.main
+
+import androidx.annotation.CheckResult
+import androidx.lifecycle.Lifecycle.Event.ON_RESUME
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.OnLifecycleEvent
+import com.afollestad.nocknock.data.AppDatabase
+import com.afollestad.nocknock.data.allSites
+import com.afollestad.nocknock.data.deleteSite
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.di.viewmodels.ScopedViewModel
+import com.afollestad.nocknock.engine.validation.ValidationManager
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.di.qualifiers.IoDispatcher
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+/** @author Aidan Follestad (@afollestad) */
+class MainViewModel @Inject constructor(
+ private val database: AppDatabase,
+ private val notificationManager: NockNotificationManager,
+ private val validationManager: ValidationManager,
+ @field:IoDispatcher private val ioDispatcher: CoroutineDispatcher
+) : ScopedViewModel(), LifecycleObserver {
+
+ private val sites = MutableLiveData>()
+ private val isLoading = MutableLiveData()
+
+ @CheckResult fun onSites(): LiveData> = sites
+
+ @CheckResult fun onIsLoading(): LiveData = isLoading
+
+ @OnLifecycleEvent(ON_RESUME)
+ fun onResume() = loadSites()
+
+ fun postSiteUpdate(model: Site) {
+ val currentSites = sites.value ?: return
+ val index = currentSites.indexOfFirst { it.id == model.id }
+ if (index == -1) return
+ sites.value = currentSites.toMutableList()
+ .apply {
+ this[index] = model
+ }
+ }
+
+ fun refreshSite(model: Site) {
+ validationManager.scheduleCheck(
+ site = model,
+ rightNow = true,
+ cancelPrevious = true
+ )
+ }
+
+ fun removeSite(model: Site) {
+ validationManager.cancelCheck(model)
+ notificationManager.cancelStatusNotification(model)
+
+ scope.launch {
+ isLoading.value = true
+ withContext(ioDispatcher) { database.deleteSite(model) }
+ val currentSites = sites.value ?: return@launch
+ val index = currentSites.indexOfFirst { it.id == model.id }
+ isLoading.value = false
+ if (index == -1) return@launch
+
+ sites.value = currentSites.toMutableList()
+ .apply {
+ removeAt(index)
+ }
+ }
+ }
+
+ private fun loadSites() {
+ scope.launch {
+ notificationManager.cancelStatusNotifications()
+ sites.value = listOf()
+ isLoading.value = true
+
+ val result = withContext(ioDispatcher) {
+ database.allSites()
+ }
+
+ sites.value = result
+ ensureCheckJobs()
+ isLoading.value = false
+ }
+ }
+
+ private suspend fun ensureCheckJobs() {
+ withContext(ioDispatcher) {
+ validationManager.ensureScheduledChecks()
+ }
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt
index b383aeb..6904345 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt
@@ -16,35 +16,24 @@
package com.afollestad.nocknock.ui.viewsite
import android.annotation.SuppressLint
-import android.content.BroadcastReceiver
-import android.content.Context
import android.content.Intent
-import android.content.IntentFilter
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.ViewModelProviders
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode
-import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
-import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
-import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
-import com.afollestad.nocknock.data.model.indexToValidationMode
-import com.afollestad.nocknock.data.model.textRes
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
-import com.afollestad.nocknock.utilities.ext.ScopeReceiver
-import com.afollestad.nocknock.utilities.ext.formatDate
+import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.utilities.ext.injector
-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.viewcomponents.ext.attachLiveData
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
-import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onScroll
-import com.afollestad.nocknock.viewcomponents.ext.showOrHide
-import com.afollestad.nocknock.viewcomponents.ext.textAsInt
-import com.afollestad.nocknock.viewcomponents.ext.trimmedText
+import com.afollestad.nocknock.viewcomponents.ext.toViewError
+import com.afollestad.nocknock.viewcomponents.ext.toViewText
+import com.afollestad.nocknock.viewcomponents.ext.toViewVisibility
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
@@ -55,7 +44,6 @@ import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress
import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
-import kotlinx.android.synthetic.main.activity_viewsite.rootView
import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_viewsite.scrollView
import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult
@@ -63,21 +51,21 @@ 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 java.lang.System.currentTimeMillis
import javax.inject.Inject
-import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
-class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
+class ViewSiteActivity : AppCompatActivity() {
- @Inject lateinit var presenter: ViewSitePresenter
+ @Inject lateinit var viewModelFactory: ViewModelProvider.Factory
- private val intentReceiver = object : BroadcastReceiver() {
- override fun onReceive(
- context: Context,
- intent: Intent
- ) = presenter.onBroadcast(intent)
+ internal val viewModel by lazy {
+ return@lazy ViewModelProviders.of(this, viewModelFactory)
+ .get(ViewSiteViewModel::class.java)
}
+ private val statusUpdateReceiver =
+ StatusUpdateIntentReceiver(application) {
+ viewModel.setModel(it)
+ }
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
@@ -85,18 +73,102 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
injector().injectInto(this)
setContentView(R.layout.activity_viewsite)
+ setupUi()
+ lifecycle.run {
+ addObserver(viewModel)
+ addObserver(statusUpdateReceiver)
+ }
+
+ // Loading
+ loadingProgress.observe(this, viewModel.onIsLoading())
+
+ // Status
+ viewModel.status.observe(this, Observer {
+ iconStatus.setStatus(it)
+ invalidateMenuForStatus(it)
+ })
+
+ // Name
+ inputName.attachLiveData(this, viewModel.name)
+ viewModel.onNameError()
+ .toViewError(this, inputName)
+
+ // Url
+ inputUrl.attachLiveData(this, viewModel.url)
+ viewModel.onUrlError()
+ .toViewError(this, inputUrl)
+ viewModel.onUrlWarningVisibility()
+ .toViewVisibility(this, textUrlWarning)
+
+ // Timeout
+ responseTimeoutInput.attachLiveData(this, viewModel.timeout)
+ viewModel.onTimeoutError()
+ .toViewError(this, responseTimeoutInput)
+
+ // Validation mode
+ responseValidationMode.attachLiveData(this, viewModel.validationMode,
+ { ValidationMode.fromIndex(it) },
+ { it.toIndex() })
+ viewModel.onValidationSearchTermError()
+ .toViewError(this, responseValidationSearchTerm)
+ viewModel.onValidationModeDescription()
+ .toViewText(this, validationModeDescription)
+
+ // Validation search term
+ responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
+ viewModel.onValidationSearchTermVisibility()
+ .toViewVisibility(this, responseValidationSearchTerm)
+
+ // Validation script
+ scriptInputLayout.attach(
+ codeData = viewModel.validationScript,
+ errorData = viewModel.onValidationScriptError(),
+ visibility = viewModel.onValidationScriptVisibility()
+ )
+
+ // Check interval
+ checkIntervalLayout.attach(
+ valueData = viewModel.checkIntervalValue,
+ multiplierData = viewModel.checkIntervalUnit,
+ errorData = viewModel.onCheckIntervalError()
+ )
+
+ // Last/next check
+ viewModel.onLastCheckResultText()
+ .toViewText(this, textLastCheckResult)
+ viewModel.onNextCheckText()
+ .toViewText(this, textNextCheck)
+
+ // Disabled button
+ viewModel.onDisableChecksVisibility()
+ .toViewVisibility(this, disableChecksButton)
+ disableChecksButton.setOnClickListener { maybeDisableChecks() }
+
+ // Done button
+ viewModel.onDoneButtonText()
+ .toViewText(this, doneBtn)
+ doneBtn.setOnClickListener {
+ viewModel.commit { finish() }
+ }
+
+ // Populate view model with initial data
+ val model = intent.getSerializableExtra(KEY_SITE) as Site
+ viewModel.setModel(model)
+ }
+
+ private fun setupUi() {
toolbar.run {
setNavigationOnClickListener { finish() }
inflateMenu(R.menu.menu_viewsite)
menu.findItem(R.id.refresh)
.setActionView(R.layout.menu_item_refresh_icon)
.apply {
- actionView.setOnClickListener { presenter.checkNow() }
+ actionView.setOnClickListener { viewModel.checkNow() }
}
setOnMenuItemClickListener {
maybeRemoveSite()
- return@setOnMenuItemClickListener true
+ true
}
}
@@ -108,10 +180,6 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
}
}
- inputUrl.setOnFocusChangeListener { _, hasFocus ->
- presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
- }
-
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
@@ -119,164 +187,13 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
)
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
-
- responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
-
- doneBtn.setOnClickListener {
- val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
- val validationMode =
- responseValidationMode.selectedItemPosition.indexToValidationMode()
- val defaultTimeout = getString(R.string.response_timeout_default).toInt()
-
- presenter.commit(
- name = inputName.trimmedText(),
- url = inputUrl.trimmedText(),
- checkInterval = checkInterval,
- validationMode = validationMode,
- validationArgs = validationMode.validationContent(),
- networkTimeout = responseTimeoutInput.textAsInt(defaultValue = defaultTimeout)
- )
- }
-
- disableChecksButton.setOnClickListener { maybeDisableChecks() }
-
- presenter.takeView(this, intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
- presenter.onNewIntent(intent)
- }
-
- override fun onDestroy() {
- presenter.dropView()
- super.onDestroy()
- }
-
- override fun setLoading() = loadingProgress.setLoading()
-
- override fun setDoneLoading() = loadingProgress.setDone()
-
- override fun showOrHideUrlSchemeWarning(show: Boolean) {
- textUrlWarning.showOrHide(show)
- if (show) {
- textUrlWarning.setText(R.string.warning_http_url)
+ if (intent != null && intent.hasExtra(KEY_SITE)) {
+ val newModel = intent.getSerializableExtra(KEY_SITE) as Site
+ viewModel.setModel(newModel)
}
}
-
- override fun showOrHideValidationSearchTerm(show: Boolean) =
- responseValidationSearchTerm.showOrHide(show)
-
- override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show)
-
- override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res)
-
- override fun displayModel(model: Site) = with(model) {
- val siteSettings = this.settings
- requireNotNull(siteSettings) { "Site settings must be populated." }
-
- iconStatus.setStatus(this.lastResult?.status ?: WAITING)
- inputName.setText(this.name)
- inputUrl.setText(this.url)
-
- if (this.lastResult == null) {
- textLastCheckResult.setText(R.string.none)
- } else {
- val statusText = this.lastResult!!.status.textRes()
- textLastCheckResult.text = if (statusText == 0) {
- this.lastResult!!.reason
- } else {
- getString(statusText)
- }
- }
-
- if (siteSettings.disabled) {
- textNextCheck.setText(R.string.auto_checks_disabled)
- } else {
- val lastCheck = this.lastResult?.timestampMs ?: currentTimeMillis()
- textNextCheck.text = (lastCheck + siteSettings.validationIntervalMs).formatDate()
- }
- checkIntervalLayout.set(siteSettings.validationIntervalMs)
-
- responseValidationMode.setSelection(siteSettings.validationMode.value - 1)
- when (siteSettings.validationMode) {
- TERM_SEARCH -> responseValidationSearchTerm.setText(siteSettings.validationArgs ?: "")
- JAVASCRIPT -> scriptInputLayout.setCode(siteSettings.validationArgs)
- else -> {
- responseValidationSearchTerm.setText("")
- scriptInputLayout.clear()
- }
- }
-
- responseTimeoutInput.setText(siteSettings.networkTimeout.toString())
-
- disableChecksButton.showOrHide(!siteSettings.disabled)
- doneBtn.setText(
- if (siteSettings.disabled) R.string.renable_and_save_changes
- else R.string.save_changes
- )
-
- invalidateMenuForStatus(model)
- }
-
- override fun setInputErrors(errors: InputErrors) {
- inputName.error = if (errors.name != null) {
- getString(errors.name!!)
- } else {
- null
- }
- inputUrl.error = if (errors.url != null) {
- getString(errors.url!!)
- } else {
- null
- }
- checkIntervalLayout.setError(
- if (errors.checkInterval != null) {
- getString(errors.checkInterval!!)
- } else {
- null
- }
- )
- responseValidationSearchTerm.error = if (errors.termSearch != null) {
- getString(errors.termSearch!!)
- } else {
- null
- }
- scriptInputLayout.setError(
- if (errors.javaScript != null) {
- getString(errors.javaScript!!)
- } else {
- null
- }
- )
- responseTimeoutInput.error = if (errors.networkTimeout != null) {
- getString(errors.networkTimeout!!)
- } else {
- null
- }
- }
-
- override fun scopeWhileAttached(
- context: CoroutineContext,
- exec: ScopeReceiver
- ) = rootView.scopeWhileAttached(context, exec)
-
- override fun onResume() {
- super.onResume()
- val filter = IntentFilter().apply {
- addAction(ACTION_STATUS_UPDATE)
- }
- safeRegisterReceiver(intentReceiver, filter)
- }
-
- override fun onPause() {
- super.onPause()
- safeUnregisterReceiver(intentReceiver)
- }
-
- private fun ValidationMode.validationContent() = when (this) {
- STATUS_CODE -> null
- TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
- JAVASCRIPT -> scriptInputLayout.getCode()
- }
}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt
index 6888d76..e9bfe1a 100644
--- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt
@@ -18,39 +18,42 @@ package com.afollestad.nocknock.ui.viewsite
import android.widget.ImageView
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.R
-import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.isPending
import com.afollestad.nocknock.toHtml
import com.afollestad.nocknock.utilities.ext.animateRotation
import kotlinx.android.synthetic.main.activity_viewsite.toolbar
+const val KEY_SITE = "site_model"
+
internal fun ViewSiteActivity.maybeRemoveSite() {
- val model = presenter.currentModel()
+ val model = viewModel.site
MaterialDialog(this).show {
title(R.string.remove_site)
message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
- positiveButton(R.string.remove) { presenter.removeSite() }
+ positiveButton(R.string.remove) {
+ viewModel.removeSite { finish() }
+ }
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.maybeDisableChecks() {
- val model = presenter.currentModel()
+ val model = viewModel.site
MaterialDialog(this).show {
title(R.string.disable_automatic_checks)
message(
text = context.getString(R.string.disable_automatic_checks_prompt, model.name).toHtml()
)
- positiveButton(R.string.disable) { presenter.disableChecks() }
+ positiveButton(R.string.disable) { viewModel.disable() }
negativeButton(android.R.string.cancel)
}
}
-internal fun ViewSiteActivity.invalidateMenuForStatus(model: Site) {
+internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) {
val refreshIcon = toolbar.menu.findItem(R.id.refresh)
.actionView as ImageView
-
- if (model.lastResult?.status.isPending()) {
+ if (status.isPending()) {
refreshIcon.animateRotation()
} else {
refreshIcon.run {
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt
deleted file mode 100644
index 4f936d2..0000000
--- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt
+++ /dev/null
@@ -1,301 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.afollestad.nocknock.ui.viewsite
-
-import android.content.Intent
-import androidx.annotation.CheckResult
-import com.afollestad.nocknock.R
-import com.afollestad.nocknock.data.AppDatabase
-import com.afollestad.nocknock.data.deleteSite
-import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.data.model.Status.WAITING
-import com.afollestad.nocknock.data.model.ValidationMode
-import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
-import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
-import com.afollestad.nocknock.data.updateSite
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_UPDATE_MODEL
-import com.afollestad.nocknock.engine.statuscheck.ValidationManager
-import com.afollestad.nocknock.notifications.NockNotificationManager
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Dispatchers.Main
-import kotlinx.coroutines.async
-import kotlinx.coroutines.launch
-import okhttp3.HttpUrl
-import org.jetbrains.annotations.TestOnly
-import javax.inject.Inject
-
-const val KEY_VIEW_MODEL = "site_model"
-
-/** @author Aidan Follestad (@afollestad) */
-data class InputErrors(
- var name: Int? = null,
- var url: Int? = null,
- var checkInterval: Int? = null,
- var termSearch: Int? = null,
- var javaScript: Int? = null,
- var networkTimeout: Int? = null
-) {
- @CheckResult fun any(): Boolean {
- return name != null || url != null || checkInterval != null ||
- termSearch != null || javaScript != null || networkTimeout != null
- }
-}
-
-/** @author Aidan Follestad (@afollestad) */
-interface ViewSitePresenter {
-
- fun takeView(
- view: ViewSiteView,
- intent: Intent
- )
-
- fun onBroadcast(intent: Intent)
-
- fun onNewIntent(intent: Intent?)
-
- fun onUrlInputFocusChange(
- focused: Boolean,
- content: String
- )
-
- fun onValidationModeSelected(index: Int)
-
- fun commit(
- name: String,
- url: String,
- checkInterval: Long,
- validationMode: ValidationMode,
- validationArgs: String?,
- networkTimeout: Int
- )
-
- fun checkNow()
-
- fun disableChecks()
-
- fun removeSite()
-
- fun currentModel(): Site
-
- fun dropView()
-}
-
-/** @author Aidan Follestad (@afollestad) */
-class RealViewSitePresenter @Inject constructor(
- private val database: AppDatabase,
- private val checkStatusManager: ValidationManager,
- private val notificationManager: NockNotificationManager
-) : ViewSitePresenter {
-
- private var view: ViewSiteView? = null
- private var currentModel: Site? = null
-
- override fun takeView(
- view: ViewSiteView,
- intent: Intent
- ) {
- this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as Site
- this.view = view.apply {
- displayModel(currentModel!!)
- }
- }
-
- override fun onBroadcast(intent: Intent) {
- if (intent.action == ACTION_STATUS_UPDATE) {
- val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site
- ?: return
- this.currentModel = model
- view?.displayModel(model)
- }
- }
-
- override fun onNewIntent(intent: Intent?) {
- if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) {
- currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as Site
- view?.displayModel(currentModel!!)
- }
- }
-
- override fun onUrlInputFocusChange(
- focused: Boolean,
- content: String
- ) {
- if (content.isEmpty() || focused) {
- return
- }
- val url = HttpUrl.parse(content)
- if (url == null ||
- (url.scheme() != "http" &&
- url.scheme() != "https")
- ) {
- view?.showOrHideUrlSchemeWarning(true)
- } else {
- view?.showOrHideUrlSchemeWarning(false)
- }
- }
-
- override fun onValidationModeSelected(index: Int) = with(view!!) {
- showOrHideValidationSearchTerm(index == 1)
- showOrHideScriptInput(index == 2)
- setValidationModeDescription(
- when (index) {
- 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: $index")
- }
- )
- }
-
- override fun commit(
- name: String,
- url: String,
- checkInterval: Long,
- validationMode: ValidationMode,
- validationArgs: String?,
- networkTimeout: Int
- ) {
- val inputErrors = InputErrors()
-
- if (name.isEmpty()) {
- inputErrors.name = R.string.please_enter_name
- }
- if (url.isEmpty()) {
- inputErrors.url = R.string.please_enter_url
- } else if (HttpUrl.parse(url) == null) {
- inputErrors.url = R.string.please_enter_valid_url
- }
- if (checkInterval <= 0) {
- inputErrors.checkInterval = R.string.please_enter_check_interval
- }
- if (validationMode == TERM_SEARCH && validationArgs.isNullOrEmpty()) {
- inputErrors.termSearch = R.string.please_enter_search_term
- } else if (validationMode == JAVASCRIPT && validationArgs.isNullOrEmpty()) {
- inputErrors.javaScript = R.string.please_enter_javaScript
- }
- if (networkTimeout <= 0) {
- inputErrors.networkTimeout = R.string.please_enter_networkTimeout
- }
-
- if (inputErrors.any()) {
- view?.setInputErrors(inputErrors)
- return
- }
-
- val updatedSettings = currentModel!!.settings!!.copy(
- validationIntervalMs = checkInterval,
- validationMode = validationMode,
- validationArgs = validationArgs,
- disabled = false,
- networkTimeout = networkTimeout
- )
- val updatedModel = currentModel!!.copy(
- name = name,
- url = url,
- settings = updatedSettings
- )
- .withStatus(status = WAITING)
-
- with(view!!) {
- scopeWhileAttached(Main) {
- launch(coroutineContext) {
- setLoading()
- async(IO) {
- database.updateSite(updatedModel)
- }.await()
-
- checkStatusManager.scheduleCheck(
- site = updatedModel,
- rightNow = true,
- cancelPrevious = true
- )
-
- setDoneLoading()
- view?.finish()
- }
- }
- }
- }
-
- override fun checkNow() = with(view!!) {
- val checkModel = currentModel!!.withStatus(
- status = WAITING
- )
- view?.displayModel(checkModel)
- checkStatusManager.scheduleCheck(
- site = checkModel,
- rightNow = true,
- cancelPrevious = true
- )
- }
-
- override fun disableChecks() {
- val site = currentModel!!
- checkStatusManager.cancelCheck(site)
- notificationManager.cancelStatusNotification(site)
-
- with(view!!) {
- scopeWhileAttached(Main) {
- launch(coroutineContext) {
- setLoading()
- currentModel = currentModel!!.copy(
- settings = currentModel!!.settings!!.copy(
- disabled = true
- )
- )
-
- async(IO) {
- database.updateSite(currentModel!!)
- }.await()
-
- setDoneLoading()
- view?.displayModel(currentModel!!)
- }
- }
- }
- }
-
- override fun removeSite() {
- val site = currentModel!!
- checkStatusManager.cancelCheck(site)
- notificationManager.cancelStatusNotification(site)
-
- with(view!!) {
- scopeWhileAttached(Main) {
- launch(coroutineContext) {
- setLoading()
- async(IO) {
- database.deleteSite(site)
- }.await()
- setDoneLoading()
- view?.finish()
- }
- }
- }
- }
-
- override fun currentModel() = this.currentModel!!
-
- override fun dropView() {
- view = null
- currentModel = null
- }
-
- @TestOnly fun setModel(model: Site) {
- this.currentModel = model
- }
-}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt
new file mode 100644
index 0000000..4d7b914
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt
@@ -0,0 +1,320 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.viewsite
+
+import android.app.Application
+import androidx.annotation.CheckResult
+import androidx.lifecycle.LifecycleObserver
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.afollestad.nocknock.R
+import com.afollestad.nocknock.data.AppDatabase
+import com.afollestad.nocknock.data.deleteSite
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.Status
+import com.afollestad.nocknock.data.model.Status.WAITING
+import com.afollestad.nocknock.data.model.ValidationMode
+import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
+import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
+import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
+import com.afollestad.nocknock.data.model.ValidationResult
+import com.afollestad.nocknock.data.model.textRes
+import com.afollestad.nocknock.data.updateSite
+import com.afollestad.nocknock.di.viewmodels.ScopedViewModel
+import com.afollestad.nocknock.engine.validation.ValidationManager
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.utilities.ext.formatDate
+import com.afollestad.nocknock.di.qualifiers.IoDispatcher
+import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
+import com.afollestad.nocknock.viewcomponents.ext.map
+import com.afollestad.nocknock.viewcomponents.ext.zip
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import java.lang.System.currentTimeMillis
+import javax.inject.Inject
+
+/** @author Aidan Follestad (@afollestad) */
+class ViewSiteViewModel @Inject constructor(
+ private val app: Application,
+ private val database: AppDatabase,
+ private val notificationManager: NockNotificationManager,
+ private val validationManager: ValidationManager,
+ @field:IoDispatcher private val ioDispatcher: CoroutineDispatcher
+) : ScopedViewModel(), LifecycleObserver {
+
+ lateinit var site: Site
+
+ // Public properties
+ val status = MutableLiveData()
+ val name = MutableLiveData()
+ val url = MutableLiveData()
+ val timeout = MutableLiveData()
+ val validationMode = MutableLiveData()
+ val validationSearchTerm = MutableLiveData()
+ val validationScript = MutableLiveData()
+ val checkIntervalValue = MutableLiveData()
+ val checkIntervalUnit = MutableLiveData()
+ internal val disabled = MutableLiveData()
+ internal val lastResult = MutableLiveData()
+
+ // Private properties
+ private val isLoading = MutableLiveData()
+ private val nameError = MutableLiveData()
+ private val urlError = MutableLiveData()
+ private val timeoutError = MutableLiveData()
+ private val validationSearchTermError = MutableLiveData()
+ private val validationScriptError = MutableLiveData()
+ private val checkIntervalValueError = MutableLiveData()
+
+ // Expose private properties or calculated properties
+ @CheckResult fun onIsLoading(): LiveData = isLoading
+
+ @CheckResult fun onNameError(): LiveData = nameError
+
+ @CheckResult fun onUrlError(): LiveData = urlError
+
+ @CheckResult fun onUrlWarningVisibility(): LiveData {
+ return url.map {
+ val parsed = HttpUrl.parse(it)
+ return@map it.isNotEmpty() &&
+ parsed != null &&
+ parsed.scheme() != "http" &&
+ parsed.scheme() != "https"
+ }
+ }
+
+ @CheckResult fun onTimeoutError(): LiveData = timeoutError
+
+ @CheckResult fun onValidationModeDescription(): LiveData {
+ return validationMode.map {
+ when (it) {
+ STATUS_CODE -> R.string.validation_mode_status_desc
+ TERM_SEARCH -> R.string.validation_mode_term_desc
+ JAVASCRIPT -> R.string.validation_mode_javascript_desc
+ else -> throw IllegalStateException("Unknown validation mode: $it")
+ }
+ }
+ }
+
+ @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError
+
+ @CheckResult fun onValidationSearchTermVisibility() =
+ validationMode.map { it == TERM_SEARCH }
+
+ @CheckResult fun onValidationScriptError(): LiveData = validationScriptError
+
+ @CheckResult fun onValidationScriptVisibility() =
+ validationMode.map { it == JAVASCRIPT }
+
+ @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError
+
+ @CheckResult fun onDisableChecksVisibility(): LiveData =
+ disabled.map { !it }
+
+ @CheckResult fun onDoneButtonText(): LiveData =
+ disabled.map {
+ if (it) R.string.renable_and_save_changes
+ else R.string.save_changes
+ }
+
+ @CheckResult fun onLastCheckResultText(): LiveData = lastResult.map {
+ if (it == null) {
+ app.getString(R.string.none)
+ } else {
+ val statusText = it.status.textRes()
+ if (statusText == 0) {
+ it.reason
+ } else {
+ app.getString(statusText)
+ }
+ }
+ }
+
+ @CheckResult fun onNextCheckText(): LiveData {
+ return zip(disabled, lastResult)
+ .map {
+ val disabled = it.first
+ val lastResult = it.second
+ if (disabled) {
+ app.getString(R.string.auto_checks_disabled)
+ } else {
+ val lastCheck = lastResult?.timestampMs ?: currentTimeMillis()
+ (lastCheck + getCheckIntervalMs()).formatDate()
+ }
+ }
+ }
+
+ // Actions
+ fun commit(done: () -> Unit) {
+ scope.launch {
+ val updatedModel = getUpdatedDbModel() ?: return@launch
+ isLoading.value = true
+
+ withContext(ioDispatcher) {
+ database.updateSite(updatedModel)
+ }
+ validationManager.scheduleCheck(
+ site = updatedModel,
+ rightNow = true,
+ cancelPrevious = true
+ )
+
+ isLoading.value = false
+ done()
+ }
+ }
+
+ fun checkNow() {
+ val checkModel = site.withStatus(
+ status = WAITING
+ )
+ setModel(checkModel)
+ validationManager.scheduleCheck(
+ site = checkModel,
+ rightNow = true,
+ cancelPrevious = true
+ )
+ }
+
+ fun removeSite(done: () -> Unit) {
+ validationManager.cancelCheck(site)
+ notificationManager.cancelStatusNotification(site)
+
+ scope.launch {
+ isLoading.value = true
+ withContext(ioDispatcher) {
+ database.deleteSite(site)
+ }
+ isLoading.value = false
+ done()
+ }
+ }
+
+ fun disable() {
+ validationManager.cancelCheck(site)
+ notificationManager.cancelStatusNotification(site)
+
+ scope.launch {
+ isLoading.value = true
+ val newModel = site.copy(
+ settings = site.settings!!.copy(
+ disabled = true
+ )
+ )
+ withContext(ioDispatcher) {
+ database.updateSite(newModel)
+ }
+ isLoading.value = false
+ setModel(newModel)
+ }
+ }
+
+ // Utilities
+ private fun getCheckIntervalMs(): Long {
+ val value = checkIntervalValue.value ?: return 0
+ val unit = checkIntervalUnit.value ?: return 0
+ return value * unit
+ }
+
+ private fun getValidationArgs(): String? {
+ return when (validationMode.value) {
+ TERM_SEARCH -> validationSearchTerm.value
+ JAVASCRIPT -> validationScript.value
+ else -> null
+ }
+ }
+
+ private fun getUpdatedDbModel(): Site? {
+ var errorCount = 0
+
+ // Validation name
+ if (name.value.isNullOrEmpty()) {
+ nameError.value = R.string.please_enter_name
+ errorCount++
+ } else {
+ nameError.value = null
+ }
+
+ // Validate URL
+ when {
+ url.value.isNullOrEmpty() -> {
+ urlError.value = R.string.please_enter_url
+ errorCount++
+ }
+ HttpUrl.parse(url.value!!) == null -> {
+ urlError.value = R.string.please_enter_valid_url
+ errorCount++
+ }
+ else -> {
+ urlError.value = null
+ }
+ }
+
+ // Validate timeout
+ if (timeout.value.isNullOrLessThan(1)) {
+ timeoutError.value = R.string.please_enter_networkTimeout
+ errorCount++
+ } else {
+ timeoutError.value = null
+ }
+
+ // Validate check interval
+ if (checkIntervalValue.value.isNullOrLessThan(1)) {
+ checkIntervalValueError.value = R.string.please_enter_check_interval
+ errorCount++
+ } else {
+ checkIntervalValueError.value = null
+ }
+
+ // Validate arguments
+ if (validationMode == TERM_SEARCH &&
+ validationSearchTerm.value.isNullOrEmpty()
+ ) {
+ errorCount++
+ validationSearchTermError.value = R.string.please_enter_search_term
+ validationScriptError.value = null
+ } else if (validationMode == JAVASCRIPT &&
+ validationScript.value.isNullOrEmpty()
+ ) {
+ errorCount++
+ validationSearchTermError.value = null
+ validationScriptError.value = R.string.please_enter_javaScript
+ } else {
+ validationSearchTermError.value = null
+ validationScriptError.value = null
+ }
+
+ if (errorCount > 0) {
+ return null
+ }
+
+ val newSettings = site.settings!!.copy(
+ validationIntervalMs = getCheckIntervalMs(),
+ validationMode = validationMode.value!!,
+ validationArgs = getValidationArgs(),
+ networkTimeout = timeout.value!!,
+ disabled = false
+ )
+ return site.copy(
+ name = name.value!!,
+ url = url.value!!,
+ settings = newSettings
+ )
+ .withStatus(status = WAITING)
+ }
+}
diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt
new file mode 100644
index 0000000..9f0a81c
--- /dev/null
+++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt
@@ -0,0 +1,98 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.viewsite
+
+import com.afollestad.nocknock.data.model.Site
+import com.afollestad.nocknock.data.model.Status.WAITING
+import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
+import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
+import com.afollestad.nocknock.utilities.ext.DAY
+import com.afollestad.nocknock.utilities.ext.HOUR
+import com.afollestad.nocknock.utilities.ext.MINUTE
+import com.afollestad.nocknock.utilities.ext.WEEK
+import kotlin.math.ceil
+
+fun ViewSiteViewModel.setModel(site: Site) {
+ requireNotNull(site.settings) {
+ "Settings must be populated!"
+ }
+ this.site = site
+ val settings = site.settings!!
+
+ status.value = site.lastResult?.status ?: WAITING
+ name.value = site.name
+ url.value = site.url
+ timeout.value = settings.networkTimeout
+
+ validationMode.value = settings.validationMode
+ when (settings.validationMode) {
+ TERM_SEARCH -> {
+ validationSearchTerm.value = settings.validationArgs
+ validationScript.value = null
+ }
+ JAVASCRIPT -> {
+ validationSearchTerm.value = null
+ validationScript.value = settings.validationArgs
+ }
+ else -> {
+ validationSearchTerm.value = null
+ validationScript.value = null
+ }
+ }
+
+ setCheckInterval(settings.validationIntervalMs)
+
+ this.disabled.value = settings.disabled
+ this.lastResult.value = site.lastResult
+}
+
+private fun ViewSiteViewModel.setCheckInterval(interval: Long) {
+ when {
+ interval >= WEEK -> {
+ checkIntervalValue.value =
+ getIntervalFromUnit(interval, WEEK)
+ checkIntervalUnit.value = WEEK
+ }
+ interval >= DAY -> {
+ checkIntervalValue.value =
+ getIntervalFromUnit(interval, DAY)
+ checkIntervalUnit.value = DAY
+ }
+ interval >= HOUR -> {
+ checkIntervalValue.value =
+ getIntervalFromUnit(interval, HOUR)
+ checkIntervalUnit.value = HOUR
+ }
+ interval >= MINUTE -> {
+ checkIntervalValue.value =
+ getIntervalFromUnit(interval, MINUTE)
+ checkIntervalUnit.value = MINUTE
+ }
+ else -> {
+ checkIntervalValue.value = 0
+ checkIntervalUnit.value = MINUTE
+ }
+ }
+}
+
+private fun getIntervalFromUnit(
+ millis: Long,
+ unit: Long
+): Int {
+ val intervalFloat = millis.toFloat()
+ val byFloat = unit.toFloat()
+ return ceil(intervalFloat / byFloat).toInt()
+}
diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml
index c19fba1..69a29d9 100644
--- a/app/src/main/res/layout/activity_addsite.xml
+++ b/app/src/main/res/layout/activity_addsite.xml
@@ -87,13 +87,13 @@
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:visibility="gone"
- tools:text="Warning: this app checks for server availability with HTTP requests. It's recommended that you use an HTTP URL."
+ android:text="@string/warning_http_url"
style="@style/NockText.Footnote"
/>
-
-
-
- ()
- private val view = mock()
-
- private val presenter = RealAddSitePresenter(
- database,
- checkStatusManager
- )
-
- @Before fun setup() {
- doAnswer {
- val exec = it.getArgument(1)
- runBlocking { exec() }
- Unit
- }.whenever(view)
- .scopeWhileAttached(any(), any())
-
- presenter.takeView(view)
- }
-
- @After fun destroy() {
- presenter.dropView()
- }
-
- @Test fun onUrlInputFocusChange_focused() {
- presenter.onUrlInputFocusChange(true, "hello")
- verifyNoMoreInteractions(view)
- }
-
- @Test fun onUrlInputFocusChange_empty() {
- presenter.onUrlInputFocusChange(false, "")
- verifyNoMoreInteractions(view)
- }
-
- @Test fun onUrlInputFocusChange_notHttpHttps() {
- presenter.onUrlInputFocusChange(false, "ftp://hello.com")
- verify(view).showOrHideUrlSchemeWarning(true)
- }
-
- @Test fun onUrlInputFocusChange_isHttpOrHttps() {
- presenter.onUrlInputFocusChange(false, "http://hello.com")
- presenter.onUrlInputFocusChange(false, "https://hello.com")
- verify(view, times(2)).showOrHideUrlSchemeWarning(false)
- }
-
- @Test fun onValidationModeSelected_statusCode() {
- presenter.onValidationModeSelected(0)
- verify(view).showOrHideValidationSearchTerm(false)
- verify(view).showOrHideScriptInput(false)
- verify(view).setValidationModeDescription(R.string.validation_mode_status_desc)
- }
-
- @Test fun onValidationModeSelected_termSearch() {
- presenter.onValidationModeSelected(1)
- verify(view).showOrHideValidationSearchTerm(true)
- verify(view).showOrHideScriptInput(false)
- verify(view).setValidationModeDescription(R.string.validation_mode_term_desc)
- }
-
- @Test fun onValidationModeSelected_javaScript() {
- presenter.onValidationModeSelected(2)
- verify(view).showOrHideValidationSearchTerm(false)
- verify(view).showOrHideScriptInput(true)
- verify(view).setValidationModeDescription(R.string.validation_mode_javascript_desc)
- }
-
- @Test(expected = IllegalStateException::class)
- fun onValidationModeSelected_other() {
- presenter.onValidationModeSelected(3)
- }
-
- @Test fun commit_nameError() {
- presenter.commit(
- "",
- "https://test.com",
- 1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.name).isEqualTo(R.string.please_enter_name)
- }
-
- @Test fun commit_urlEmptyError() {
- presenter.commit(
- "Testing",
- "",
- 1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.url).isEqualTo(R.string.please_enter_url)
- }
-
- @Test fun commit_urlFormatError() {
- presenter.commit(
- "Testing",
- "ftp://hello.com",
- 1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.url).isEqualTo(R.string.please_enter_valid_url)
- }
-
- @Test fun commit_checkIntervalError() {
- presenter.commit(
- "Testing",
- "https://hello.com",
- -1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.checkInterval).isEqualTo(R.string.please_enter_check_interval)
- }
-
- @Test fun commit_termSearchError() {
- presenter.commit(
- "Testing",
- "https://hello.com",
- 1,
- TERM_SEARCH,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.termSearch).isEqualTo(R.string.please_enter_search_term)
- }
-
- @Test fun commit_networkTimeout_error() {
- presenter.commit(
- "Testing",
- "https://hello.com",
- 1,
- STATUS_CODE,
- null,
- 0
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.networkTimeout).isEqualTo(R.string.please_enter_networkTimeout)
- }
-
- @Test fun commit_javaScript_error() {
- presenter.commit(
- "Testing",
- "https://hello.com",
- 1,
- JAVASCRIPT,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.javaScript).isEqualTo(R.string.please_enter_javaScript)
- }
-
- @Test fun commit_success() = runBlocking {
- presenter.commit(
- "Testing",
- "https://hello.com",
- 1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val siteCaptor = argumentCaptor()
- val settingsCaptor = argumentCaptor()
-
- verify(view).setLoading()
- verify(database.siteDao()).insert(siteCaptor.capture())
- verify(database.siteSettingsDao()).insert(settingsCaptor.capture())
- verify(database.validationResultsDao(), never()).insert(any())
-
- val settings = settingsCaptor.firstValue
- val model = siteCaptor.firstValue.copy(
- id = 1, // fill it in because our insert captor doesn't catch this
- settings = settings,
- lastResult = null
- )
-
- verify(view, never()).setInputErrors(any())
- verify(checkStatusManager).scheduleCheck(
- site = model,
- rightNow = true,
- cancelPrevious = true,
- fromFinishingJob = false
- )
-
- verify(view).setDoneLoading()
- verify(view).onSiteAdded()
- }
-}
diff --git a/app/src/test/java/com/afollestad/nocknock/LiveDataTestUtil.kt b/app/src/test/java/com/afollestad/nocknock/LiveDataTestUtil.kt
new file mode 100644
index 0000000..b03df8b
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/LiveDataTestUtil.kt
@@ -0,0 +1,33 @@
+package com.afollestad.nocknock
+
+import androidx.annotation.CheckResult
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+import com.google.common.truth.Truth.assertWithMessage
+
+/** @author Aidan Follestad (@afollestad) */
+class TestLiveData(data: LiveData) {
+
+ private val receivedValues = mutableListOf()
+ private val observer = Observer { receivedValues.add(it) }
+
+ init {
+ data.observeForever(observer)
+ }
+
+ fun assertNoValues() {
+ assertWithMessage("Expected no values, but got: $receivedValues").that(receivedValues)
+ .isEmpty()
+ }
+
+ fun assertValues(vararg assertValues: T) {
+ val assertList = assertValues.toList()
+ assertWithMessage("Expected: $assertList, but got: $receivedValues").that(receivedValues)
+ .isEqualTo(assertList)
+ receivedValues.clear()
+ }
+
+ @CheckResult fun values(): List = receivedValues
+}
+
+@CheckResult fun LiveData.test() = TestLiveData(this)
\ No newline at end of file
diff --git a/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt
deleted file mode 100644
index 04135d7..0000000
--- a/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.afollestad.nocknock
-
-import android.app.Application
-import android.content.Context.MODE_PRIVATE
-import android.content.Intent
-import android.content.SharedPreferences
-import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_UPDATE_MODEL
-import com.afollestad.nocknock.engine.statuscheck.ValidationManager
-import com.afollestad.nocknock.notifications.NockNotificationManager
-import com.afollestad.nocknock.ui.main.MainView
-import com.afollestad.nocknock.ui.main.RealMainPresenter
-import com.afollestad.nocknock.utilities.ext.ScopeReceiver
-import com.google.common.truth.Truth.assertThat
-import com.nhaarman.mockitokotlin2.any
-import com.nhaarman.mockitokotlin2.argumentCaptor
-import com.nhaarman.mockitokotlin2.doAnswer
-import com.nhaarman.mockitokotlin2.doReturn
-import com.nhaarman.mockitokotlin2.mock
-import com.nhaarman.mockitokotlin2.times
-import com.nhaarman.mockitokotlin2.verify
-import com.nhaarman.mockitokotlin2.whenever
-import kotlinx.coroutines.runBlocking
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
-
-class MainPresenterTest {
-
- private val prefs = mock {
- on { getBoolean("did_db_migration", false) } doReturn true
- }
- private val app = mock {
- on { getSharedPreferences("settings", MODE_PRIVATE) } doReturn prefs
- }
-
- private val database = mockDatabase()
-
- private val notificationManager = mock()
- private val checkStatusManager = mock()
- private val view = mock()
-
- private val presenter = RealMainPresenter(
- app,
- database,
- notificationManager,
- checkStatusManager
- )
-
- @Before fun setup() {
- doAnswer {
- val exec = it.getArgument(1)
- runBlocking { exec() }
- Unit
- }.whenever(view)
- .scopeWhileAttached(any(), any())
-
- presenter.takeView(view)
- }
-
- @After fun destroy() {
- presenter.dropView()
- }
-
- @Test fun onBroadcast() {
- val badIntent = fakeIntent("Hello World")
- presenter.onBroadcast(badIntent)
-
- val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
- whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
- .doReturn(MOCK_MODEL_2)
-
- presenter.onBroadcast(goodIntent)
- verify(view, times(1)).updateModel(MOCK_MODEL_2)
- }
-
- @Test fun resume() = runBlocking {
- presenter.resume()
-
- verify(notificationManager).cancelStatusNotifications()
-
- val modelsCaptor = argumentCaptor>()
- verify(view, times(2)).setModels(modelsCaptor.capture())
- assertThat(modelsCaptor.firstValue).isEmpty()
- assertThat(modelsCaptor.lastValue).isEqualTo(ALL_MOCK_MODELS)
- }
-
- @Test fun refreshSite() {
- presenter.refreshSite(MOCK_MODEL_3)
-
- verify(checkStatusManager).scheduleCheck(
- site = MOCK_MODEL_3,
- rightNow = true,
- cancelPrevious = true
- )
- }
-
- @Test fun removeSite() = runBlocking {
- presenter.removeSite(MOCK_MODEL_1)
-
- verify(checkStatusManager).cancelCheck(MOCK_MODEL_1)
- verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
- verify(database.siteDao()).delete(MOCK_MODEL_1)
- verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
- verify(view).onSiteDeleted(MOCK_MODEL_1)
- }
-
- private fun fakeIntent(action: String): Intent {
- return mock {
- on { getAction() } doReturn action
- }
- }
-}
diff --git a/app/src/test/java/com/afollestad/nocknock/TestUtil.kt b/app/src/test/java/com/afollestad/nocknock/TestData.kt
similarity index 96%
rename from app/src/test/java/com/afollestad/nocknock/TestUtil.kt
rename to app/src/test/java/com/afollestad/nocknock/TestData.kt
index fe5d524..aad071f 100644
--- a/app/src/test/java/com/afollestad/nocknock/TestUtil.kt
+++ b/app/src/test/java/com/afollestad/nocknock/TestData.kt
@@ -15,6 +15,7 @@
*/
package com.afollestad.nocknock
+import android.content.Intent
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.SiteDao
import com.afollestad.nocknock.data.SiteSettingsDao
@@ -32,6 +33,12 @@ import com.nhaarman.mockitokotlin2.isA
import com.nhaarman.mockitokotlin2.mock
import java.lang.System.currentTimeMillis
+fun fakeIntent(action: String): Intent {
+ return mock {
+ on { getAction() } doReturn action
+ }
+}
+
fun fakeSettingsModel(
id: Long,
validationMode: ValidationMode = STATUS_CODE
diff --git a/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt
deleted file mode 100644
index 37a78b0..0000000
--- a/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt
+++ /dev/null
@@ -1,420 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.afollestad.nocknock
-
-import android.content.Intent
-import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.data.model.SiteSettings
-import com.afollestad.nocknock.data.model.Status.ERROR
-import com.afollestad.nocknock.data.model.Status.WAITING
-import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
-import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
-import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
-import com.afollestad.nocknock.data.model.ValidationResult
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_UPDATE_MODEL
-import com.afollestad.nocknock.engine.statuscheck.ValidationManager
-import com.afollestad.nocknock.notifications.NockNotificationManager
-import com.afollestad.nocknock.ui.viewsite.InputErrors
-import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL
-import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter
-import com.afollestad.nocknock.ui.viewsite.ViewSiteView
-import com.afollestad.nocknock.utilities.ext.ScopeReceiver
-import com.google.common.truth.Truth.assertThat
-import com.nhaarman.mockitokotlin2.any
-import com.nhaarman.mockitokotlin2.argumentCaptor
-import com.nhaarman.mockitokotlin2.doAnswer
-import com.nhaarman.mockitokotlin2.doReturn
-import com.nhaarman.mockitokotlin2.mock
-import com.nhaarman.mockitokotlin2.never
-import com.nhaarman.mockitokotlin2.times
-import com.nhaarman.mockitokotlin2.verify
-import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
-import com.nhaarman.mockitokotlin2.whenever
-import kotlinx.coroutines.runBlocking
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
-import java.lang.System.currentTimeMillis
-
-class ViewSitePresenterTest {
-
- private val database = mockDatabase()
- private val checkStatusManager = mock()
- private val notificationManager = mock()
- private val view = mock()
-
- private val presenter = RealViewSitePresenter(
- database,
- checkStatusManager,
- notificationManager
- )
-
- @Before fun setup() {
- doAnswer {
- val exec = it.getArgument(1)
- runBlocking { exec() }
- Unit
- }.whenever(view)
- .scopeWhileAttached(any(), any())
-
- val intent = fakeIntent("")
- whenever(intent.getSerializableExtra(KEY_VIEW_MODEL))
- .doReturn(MOCK_MODEL_1)
- presenter.takeView(view, intent)
- assertThat(presenter.currentModel()).isEqualTo(MOCK_MODEL_1)
- verify(view, times(1)).displayModel(MOCK_MODEL_1)
- }
-
- @After fun destroy() {
- presenter.dropView()
- }
-
- @Test fun onBroadcast() {
- val badIntent = fakeIntent("Hello World")
- presenter.onBroadcast(badIntent)
-
- val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
- whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
- .doReturn(MOCK_MODEL_2)
-
- presenter.onBroadcast(goodIntent)
- assertThat(presenter.currentModel()).isEqualTo(MOCK_MODEL_2)
- verify(view, times(1)).displayModel(MOCK_MODEL_2)
- }
-
- @Test fun onNewIntent() {
- val badIntent = fakeIntent(ACTION_STATUS_UPDATE)
- presenter.onBroadcast(badIntent)
-
- val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
- whenever(goodIntent.getSerializableExtra(KEY_VIEW_MODEL))
- .doReturn(MOCK_MODEL_3)
- presenter.onBroadcast(goodIntent)
-
- verify(view, times(1)).displayModel(MOCK_MODEL_3)
- }
-
- @Test fun onUrlInputFocusChange_focused() {
- presenter.onUrlInputFocusChange(true, "hello")
- verifyNoMoreInteractions(view)
- }
-
- @Test fun onUrlInputFocusChange_empty() {
- presenter.onUrlInputFocusChange(false, "")
- verifyNoMoreInteractions(view)
- }
-
- @Test fun onUrlInputFocusChange_notHttpHttps() {
- presenter.onUrlInputFocusChange(false, "ftp://hello.com")
- verify(view).showOrHideUrlSchemeWarning(true)
- }
-
- @Test fun onUrlInputFocusChange_isHttpOrHttps() {
- presenter.onUrlInputFocusChange(false, "http://hello.com")
- presenter.onUrlInputFocusChange(false, "https://hello.com")
- verify(view, times(2)).showOrHideUrlSchemeWarning(false)
- }
-
- @Test fun onValidationModeSelected_statusCode() {
- presenter.onValidationModeSelected(0)
- verify(view).showOrHideValidationSearchTerm(false)
- verify(view).showOrHideScriptInput(false)
- verify(view).setValidationModeDescription(R.string.validation_mode_status_desc)
- }
-
- @Test fun onValidationModeSelected_termSearch() {
- presenter.onValidationModeSelected(1)
- verify(view).showOrHideValidationSearchTerm(true)
- verify(view).showOrHideScriptInput(false)
- verify(view).setValidationModeDescription(R.string.validation_mode_term_desc)
- }
-
- @Test fun onValidationModeSelected_javaScript() {
- presenter.onValidationModeSelected(2)
- verify(view).showOrHideValidationSearchTerm(false)
- verify(view).showOrHideScriptInput(true)
- verify(view).setValidationModeDescription(R.string.validation_mode_javascript_desc)
- }
-
- @Test(expected = IllegalStateException::class)
- fun onValidationModeSelected_other() {
- presenter.onValidationModeSelected(3)
- }
-
- @Test fun commit_nameError() {
- presenter.commit(
- "",
- "https://test.com",
- 1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.name).isEqualTo(R.string.please_enter_name)
- }
-
- @Test fun commit_urlEmptyError() {
- presenter.commit(
- "Testing",
- "",
- 1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.url).isEqualTo(R.string.please_enter_url)
- }
-
- @Test fun commit_urlFormatError() {
- presenter.commit(
- "Testing",
- "ftp://hello.com",
- 1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.url).isEqualTo(R.string.please_enter_valid_url)
- }
-
- @Test fun commit_checkIntervalError() {
- presenter.commit(
- "Testing",
- "https://hello.com",
- -1,
- STATUS_CODE,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.checkInterval).isEqualTo(R.string.please_enter_check_interval)
- }
-
- @Test fun commit_termSearchError() {
- presenter.commit(
- "Testing",
- "https://hello.com",
- 1,
- TERM_SEARCH,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.termSearch).isEqualTo(R.string.please_enter_search_term)
- }
-
- @Test fun commit_javaScript_error() {
- presenter.commit(
- "Testing",
- "https://hello.com",
- 1,
- JAVASCRIPT,
- null,
- 60000
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.javaScript).isEqualTo(R.string.please_enter_javaScript)
- }
-
- @Test fun commit_networkTimeout_error() {
- presenter.commit(
- "Testing",
- "https://hello.com",
- 1,
- STATUS_CODE,
- null,
- 0
- )
-
- val inputErrorsCaptor = argumentCaptor()
- verify(view).setInputErrors(inputErrorsCaptor.capture())
- verify(checkStatusManager, never())
- .scheduleCheck(any(), any(), any(), any())
-
- val errors = inputErrorsCaptor.firstValue
- assertThat(errors.networkTimeout).isEqualTo(R.string.please_enter_networkTimeout)
- }
-
- @Test fun commit_success() = runBlocking {
- val name = "Testing"
- val url = "https://hello.com"
- val checkInterval = 60000L
- val validationMode = TERM_SEARCH
- val validationArgs = "Hello World"
-
- val currentModel = presenter.currentModel()
- val initialLastResult = ValidationResult(
- siteId = currentModel.id,
- timestampMs = currentTimeMillis() - 60000,
- status = ERROR,
- reason = "Oh no!"
- )
- val disabledModel = currentModel.copy(
- settings = currentModel.settings!!.copy(disabled = true),
- lastResult = initialLastResult
- )
- presenter.setModel(disabledModel)
-
- presenter.commit(
- name,
- url,
- checkInterval,
- validationMode,
- validationArgs,
- 60000
- )
-
- val siteCaptor = argumentCaptor()
- val settingsCaptor = argumentCaptor()
- val resultCaptor = argumentCaptor()
-
- verify(view).setLoading()
- verify(database.siteDao()).update(siteCaptor.capture())
- verify(database.siteSettingsDao()).update(settingsCaptor.capture())
- verify(database.validationResultsDao()).update(resultCaptor.capture())
-
- val model = siteCaptor.firstValue
- model.apply {
- assertThat(this.name).isEqualTo(name)
- assertThat(this.url).isEqualTo(url)
- }
-
- val settings = settingsCaptor.firstValue
- settings.apply {
- assertThat(this.validationIntervalMs).isEqualTo(checkInterval)
- assertThat(this.validationArgs).isEqualTo(validationArgs)
- assertThat(this.disabled).isFalse()
- }
-
- val result = resultCaptor.firstValue
- result.apply {
- assertThat(this.status).isEqualTo(WAITING)
- assertThat(this.reason).isNull()
- assertThat(this.timestampMs).isGreaterThan(0)
- }
-
- verify(view, never()).setInputErrors(any())
- verify(checkStatusManager).scheduleCheck(
- site = model,
- rightNow = true,
- cancelPrevious = true,
- fromFinishingJob = false
- )
- verify(view).setDoneLoading()
- verify(view).finish()
- }
-
- @Test fun checkNow() {
- val newModel = presenter.currentModel()
- .withStatus(status = WAITING)
- presenter.checkNow()
-
- verify(view, never()).setLoading()
- verify(view).displayModel(newModel)
- verify(checkStatusManager).scheduleCheck(
- site = newModel,
- rightNow = true,
- cancelPrevious = true
- )
- }
-
- @Test fun disableChecks() = runBlocking {
- val model = presenter.currentModel()
- presenter.disableChecks()
-
- verify(checkStatusManager).cancelCheck(model)
- verify(notificationManager).cancelStatusNotification(model)
- verify(view).setLoading()
-
- val modelCaptor = argumentCaptor()
- val settingsCaptor = argumentCaptor()
- val resultCaptor = argumentCaptor()
-
- verify(database.siteDao()).update(modelCaptor.capture())
- verify(database.siteSettingsDao()).update(settingsCaptor.capture())
- verify(database.validationResultsDao()).update(resultCaptor.capture())
-
- val newModel = modelCaptor.firstValue
- val newSettings = settingsCaptor.firstValue
- val result = resultCaptor.firstValue
- assertThat(newSettings.disabled).isTrue()
-
- verify(view).setDoneLoading()
- verify(view, times(1)).displayModel(newModel)
- }
-
- @Test fun removeSite() = runBlocking {
- val model = presenter.currentModel()
- presenter.removeSite()
-
- verify(checkStatusManager).cancelCheck(model)
- verify(notificationManager).cancelStatusNotification(model)
- verify(view).setLoading()
-
- verify(database.siteSettingsDao()).delete(model.settings!!)
- verify(database.validationResultsDao()).delete(model.lastResult!!)
- verify(database.siteDao()).delete(model)
-
- verify(view).setDoneLoading()
- verify(view).finish()
- }
-
- private fun fakeIntent(action: String): Intent {
- return mock {
- on { getAction() } doReturn action
- }
- }
-}
diff --git a/app/src/test/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiverTest.kt b/app/src/test/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiverTest.kt
new file mode 100644
index 0000000..54d62c4
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiverTest.kt
@@ -0,0 +1,55 @@
+package com.afollestad.nocknock.broadcasts
+
+import android.app.Application
+import android.content.IntentFilter
+import com.afollestad.nocknock.MOCK_MODEL_2
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL
+import com.afollestad.nocknock.fakeIntent
+import com.google.common.truth.Truth.assertThat
+import com.nhaarman.mockitokotlin2.argumentCaptor
+import com.nhaarman.mockitokotlin2.doReturn
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.times
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import org.junit.Test
+
+/** @author Aidan Follestad (@afollestad) */
+class StatusUpdateIntentReceiverTest {
+
+ private val app = mock()
+ private val callback = mock()
+
+ private val receiver = StatusUpdateIntentReceiver(app, callback)
+
+ @Test fun onReceive() {
+ val badIntent = fakeIntent("Hello World")
+ receiver.intentReceiver.onReceive(app, badIntent)
+
+ val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
+ whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
+ .doReturn(MOCK_MODEL_2)
+
+ receiver.intentReceiver.onReceive(app, goodIntent)
+ verify(callback, times(1)).invoke(MOCK_MODEL_2)
+ }
+
+ @Test fun onResume() {
+ receiver.onResume()
+
+ val filterCaptor = argumentCaptor()
+ verify(app).registerReceiver(receiver.intentReceiver, filterCaptor.capture())
+
+ val actionIterator = filterCaptor.firstValue.actionsIterator()
+ assertThat(actionIterator.hasNext()).isTrue()
+ val filterAction = actionIterator.next()
+ assertThat(filterAction).isEqualTo(ACTION_STATUS_UPDATE)
+ assertThat(actionIterator.hasNext()).isFalse()
+ }
+
+ @Test fun onPause() {
+ receiver.onPause()
+ verify(app).unregisterReceiver(receiver.intentReceiver)
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSitePresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSitePresenterTest.kt
new file mode 100644
index 0000000..0320fd3
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSitePresenterTest.kt
@@ -0,0 +1,301 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.addsite
+
+//import com.afollestad.nocknock.R.string
+//import com.afollestad.nocknock.data.model.Site
+//import com.afollestad.nocknock.data.model.SiteSettings
+//import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
+//import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
+//import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
+//import com.afollestad.nocknock.engine.validation.ValidationManager
+//import com.afollestad.nocknock.mockDatabase
+//import com.afollestad.nocknock.ui.addsite.AddSiteView
+//import com.afollestad.nocknock.ui.addsite.InputErrors
+//import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter
+//import com.afollestad.nocknock.utilities.ext.ScopeReceiver
+//import com.google.common.truth.Truth.assertThat
+//import com.nhaarman.mockitokotlin2.any
+//import com.nhaarman.mockitokotlin2.argumentCaptor
+//import com.nhaarman.mockitokotlin2.doAnswer
+//import com.nhaarman.mockitokotlin2.mock
+//import com.nhaarman.mockitokotlin2.never
+//import com.nhaarman.mockitokotlin2.times
+//import com.nhaarman.mockitokotlin2.verify
+//import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
+//import com.nhaarman.mockitokotlin2.whenever
+//import kotlinx.coroutines.runBlocking
+//import org.junit.After
+//import org.junit.Before
+//import org.junit.Test
+//
+//class AddSitePresenterTest {
+//
+// private val database = mockDatabase()
+// private val checkStatusManager = mock()
+// private val view = mock()
+//
+// private val presenter = RealAddSitePresenter(
+// database,
+// checkStatusManager
+// )
+//
+// @Before fun setup() {
+// doAnswer {
+// val exec = it.getArgument(1)
+// runBlocking { exec() }
+// Unit
+// }.whenever(view)
+// .scopeWhileAttached(any(), any())
+//
+// presenter.takeView(view)
+// }
+//
+// @After fun destroy() {
+// presenter.dropView()
+// }
+//
+// @Test fun onUrlInputFocusChange_focused() {
+// presenter.onUrlInputFocusChange(true, "hello")
+// verifyNoMoreInteractions(view)
+// }
+//
+// @Test fun onUrlInputFocusChange_empty() {
+// presenter.onUrlInputFocusChange(false, "")
+// verifyNoMoreInteractions(view)
+// }
+//
+// @Test fun onUrlInputFocusChange_notHttpHttps() {
+// presenter.onUrlInputFocusChange(false, "ftp://hello.com")
+// verify(view).showOrHideUrlSchemeWarning(true)
+// }
+//
+// @Test fun onUrlInputFocusChange_isHttpOrHttps() {
+// presenter.onUrlInputFocusChange(false, "http://hello.com")
+// presenter.onUrlInputFocusChange(false, "https://hello.com")
+// verify(view, times(2)).showOrHideUrlSchemeWarning(false)
+// }
+//
+// @Test fun onValidationModeSelected_statusCode() {
+// presenter.onValidationModeSelected(0)
+// verify(view).showOrHideValidationSearchTerm(false)
+// verify(view).showOrHideScriptInput(false)
+// verify(view).setValidationModeDescription(
+// string.validation_mode_status_desc
+// )
+// }
+//
+// @Test fun onValidationModeSelected_termSearch() {
+// presenter.onValidationModeSelected(1)
+// verify(view).showOrHideValidationSearchTerm(true)
+// verify(view).showOrHideScriptInput(false)
+// verify(view).setValidationModeDescription(
+// string.validation_mode_term_desc
+// )
+// }
+//
+// @Test fun onValidationModeSelected_javaScript() {
+// presenter.onValidationModeSelected(2)
+// verify(view).showOrHideValidationSearchTerm(false)
+// verify(view).showOrHideScriptInput(true)
+// verify(view).setValidationModeDescription(
+// string.validation_mode_javascript_desc
+// )
+// }
+//
+// @Test(expected = IllegalStateException::class)
+// fun onValidationModeSelected_other() {
+// presenter.onValidationModeSelected(3)
+// }
+//
+// @Test fun commit_nameError() {
+// presenter.commit(
+// "",
+// "https://test.com",
+// 1,
+// STATUS_CODE,
+// null,
+// 60000
+// )
+//
+// val inputErrorsCaptor = argumentCaptor()
+// verify(view).setInputErrors(inputErrorsCaptor.capture())
+// verify(checkStatusManager, never())
+// .scheduleCheck(any(), any(), any(), any())
+//
+// val errors = inputErrorsCaptor.firstValue
+// assertThat(errors.name).isEqualTo(string.please_enter_name)
+// }
+//
+// @Test fun commit_urlEmptyError() {
+// presenter.commit(
+// "Testing",
+// "",
+// 1,
+// STATUS_CODE,
+// null,
+// 60000
+// )
+//
+// val inputErrorsCaptor = argumentCaptor()
+// verify(view).setInputErrors(inputErrorsCaptor.capture())
+// verify(checkStatusManager, never())
+// .scheduleCheck(any(), any(), any(), any())
+//
+// val errors = inputErrorsCaptor.firstValue
+// assertThat(errors.url).isEqualTo(string.please_enter_url)
+// }
+//
+// @Test fun commit_urlFormatError() {
+// presenter.commit(
+// "Testing",
+// "ftp://hello.com",
+// 1,
+// STATUS_CODE,
+// null,
+// 60000
+// )
+//
+// val inputErrorsCaptor = argumentCaptor()
+// verify(view).setInputErrors(inputErrorsCaptor.capture())
+// verify(checkStatusManager, never())
+// .scheduleCheck(any(), any(), any(), any())
+//
+// val errors = inputErrorsCaptor.firstValue
+// assertThat(errors.url).isEqualTo(string.please_enter_valid_url)
+// }
+//
+// @Test fun commit_checkIntervalError() {
+// presenter.commit(
+// "Testing",
+// "https://hello.com",
+// -1,
+// STATUS_CODE,
+// null,
+// 60000
+// )
+//
+// val inputErrorsCaptor = argumentCaptor()
+// verify(view).setInputErrors(inputErrorsCaptor.capture())
+// verify(checkStatusManager, never())
+// .scheduleCheck(any(), any(), any(), any())
+//
+// val errors = inputErrorsCaptor.firstValue
+// assertThat(errors.checkInterval).isEqualTo(
+// string.please_enter_check_interval
+// )
+// }
+//
+// @Test fun commit_termSearchError() {
+// presenter.commit(
+// "Testing",
+// "https://hello.com",
+// 1,
+// TERM_SEARCH,
+// null,
+// 60000
+// )
+//
+// val inputErrorsCaptor = argumentCaptor()
+// verify(view).setInputErrors(inputErrorsCaptor.capture())
+// verify(checkStatusManager, never())
+// .scheduleCheck(any(), any(), any(), any())
+//
+// val errors = inputErrorsCaptor.firstValue
+// assertThat(errors.termSearch).isEqualTo(
+// string.please_enter_search_term
+// )
+// }
+//
+// @Test fun commit_networkTimeout_error() {
+// presenter.commit(
+// "Testing",
+// "https://hello.com",
+// 1,
+// STATUS_CODE,
+// null,
+// 0
+// )
+//
+// val inputErrorsCaptor = argumentCaptor()
+// verify(view).setInputErrors(inputErrorsCaptor.capture())
+// verify(checkStatusManager, never())
+// .scheduleCheck(any(), any(), any(), any())
+//
+// val errors = inputErrorsCaptor.firstValue
+// assertThat(errors.networkTimeout).isEqualTo(
+// string.please_enter_networkTimeout
+// )
+// }
+//
+// @Test fun commit_javaScript_error() {
+// presenter.commit(
+// "Testing",
+// "https://hello.com",
+// 1,
+// JAVASCRIPT,
+// null,
+// 60000
+// )
+//
+// val inputErrorsCaptor = argumentCaptor()
+// verify(view).setInputErrors(inputErrorsCaptor.capture())
+// verify(checkStatusManager, never())
+// .scheduleCheck(any(), any(), any(), any())
+//
+// val errors = inputErrorsCaptor.firstValue
+// assertThat(errors.javaScript).isEqualTo(
+// string.please_enter_javaScript
+// )
+// }
+//
+// @Test fun commit_success() = runBlocking {
+// presenter.commit(
+// "Testing",
+// "https://hello.com",
+// 1,
+// STATUS_CODE,
+// null,
+// 60000
+// )
+//
+// val siteCaptor = argumentCaptor()
+// val settingsCaptor = argumentCaptor()
+//
+// verify(view).setLoading()
+// verify(database.siteDao()).insert(siteCaptor.capture())
+// verify(database.siteSettingsDao()).insert(settingsCaptor.capture())
+// verify(database.validationResultsDao(), never()).insert(any())
+//
+// val settings = settingsCaptor.firstValue
+// val model = siteCaptor.firstValue.copy(
+// id = 1, // fill it in because our insert captor doesn't catch this
+// settings = settings,
+// lastResult = null
+// )
+//
+// verify(view, never()).setInputErrors(any())
+// verify(checkStatusManager).scheduleCheck(
+// site = model,
+// rightNow = true,
+// cancelPrevious = true,
+// fromFinishingJob = false
+// )
+//
+// verify(view).setDoneLoading()
+// verify(view).onSiteAdded()
+// }
+//}
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt
new file mode 100644
index 0000000..6414bd5
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt
@@ -0,0 +1,158 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.main
+
+import com.afollestad.nocknock.ALL_MOCK_MODELS
+import com.afollestad.nocknock.MOCK_MODEL_1
+import com.afollestad.nocknock.MOCK_MODEL_2
+import com.afollestad.nocknock.MOCK_MODEL_3
+import com.afollestad.nocknock.engine.validation.ValidationManager
+import com.afollestad.nocknock.mockDatabase
+import com.afollestad.nocknock.notifications.NockNotificationManager
+import com.afollestad.nocknock.test
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Test
+
+class MainViewModelTest {
+
+ private val database = mockDatabase()
+ private val notificationManager = mock()
+ private val validationManager = mock()
+
+ private val viewModel = MainViewModel(
+ database,
+ notificationManager,
+ validationManager,
+ Dispatchers.Default
+ ).apply {
+ this.mainDispatcher = Dispatchers.Default
+ }
+
+ @After fun tearDown() = viewModel.destroy()
+
+ @Test fun onResume() = runBlocking {
+ val isLoading = viewModel.onIsLoading()
+ .test()
+ val sites = viewModel.onSites()
+ .test()
+
+ viewModel.onResume()
+
+ verify(notificationManager).cancelStatusNotifications()
+ verify(validationManager).ensureScheduledChecks()
+
+ sites.assertValues(
+ listOf(),
+ ALL_MOCK_MODELS
+ )
+ isLoading.assertValues(true, false)
+ }
+
+ @Test fun postSiteUpdate_notFound() {
+ val sites = viewModel.onSites()
+ .test()
+ viewModel.postSiteUpdate(MOCK_MODEL_1)
+ sites.assertNoValues()
+ }
+
+ @Test fun postSiteUpdate() {
+ val sites = viewModel.onSites()
+ .test()
+
+ viewModel.onResume()
+ sites.assertValues(
+ listOf(),
+ ALL_MOCK_MODELS
+ )
+
+ val updatedModel2 = MOCK_MODEL_2.copy(
+ name = "Wakanda Forever!!!"
+ )
+ val updatedSites = ALL_MOCK_MODELS.toMutableList()
+ .apply {
+ this[1] = updatedModel2
+ }
+ viewModel.postSiteUpdate(updatedModel2)
+
+ sites.assertValues(updatedSites)
+
+ }
+
+ @Test fun refreshSite() {
+ viewModel.refreshSite(MOCK_MODEL_3)
+
+ verify(validationManager).scheduleCheck(
+ site = MOCK_MODEL_3,
+ rightNow = true,
+ cancelPrevious = true
+ )
+ }
+
+ @Test fun removeSite_notFound() {
+ val sites = viewModel.onSites()
+ .test()
+ val isLoading = viewModel.onIsLoading()
+ .test()
+
+ viewModel.onResume()
+ sites.assertValues(
+ listOf(),
+ ALL_MOCK_MODELS
+ )
+
+ val modifiedModel = MOCK_MODEL_1.copy(id = 11111)
+ viewModel.removeSite(modifiedModel)
+
+ sites.assertNoValues()
+ isLoading.assertValues(true, false)
+
+ verify(validationManager).cancelCheck(modifiedModel)
+ verify(notificationManager).cancelStatusNotification(modifiedModel)
+ verify(database.siteDao()).delete(modifiedModel)
+ verify(database.siteSettingsDao()).delete(modifiedModel.settings!!)
+ }
+
+ @Test fun removeSite() {
+ val sites = viewModel.onSites()
+ .test()
+ val isLoading = viewModel.onIsLoading()
+ .test()
+
+ viewModel.onResume()
+ sites.assertValues(
+ listOf(),
+ ALL_MOCK_MODELS
+ )
+
+ val modelsWithout1 = ALL_MOCK_MODELS.toMutableList()
+ .apply {
+ removeAt(0)
+ }
+ viewModel.removeSite(MOCK_MODEL_1)
+
+ sites.assertValues(modelsWithout1)
+ isLoading.assertValues(true, false)
+
+ verify(validationManager).cancelCheck(MOCK_MODEL_1)
+ verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
+ verify(database.siteDao()).delete(MOCK_MODEL_1)
+ verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
+ }
+}
diff --git a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenterTest.kt
new file mode 100644
index 0000000..9b4a33b
--- /dev/null
+++ b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenterTest.kt
@@ -0,0 +1,20 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.ui.viewsite
+
+class ViewSitePresenterTest {
+
+}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/legacy/DbMigrator.kt b/data/src/main/java/com/afollestad/nocknock/data/legacy/DbMigrator.kt
deleted file mode 100644
index 5af752b..0000000
--- a/data/src/main/java/com/afollestad/nocknock/data/legacy/DbMigrator.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-@file:Suppress("DEPRECATION")
-
-package com.afollestad.nocknock.data.legacy
-
-import android.app.Application
-import com.afollestad.nocknock.data.AppDatabase
-import com.afollestad.nocknock.data.model.Site
-import com.afollestad.nocknock.data.model.SiteSettings
-import com.afollestad.nocknock.data.model.Status.CHECKING
-import com.afollestad.nocknock.data.model.Status.WAITING
-import com.afollestad.nocknock.data.model.ValidationResult
-import javax.inject.Inject
-
-/**
- * Migrates from manual SQLite management to Room.
- *
- * @author Aidan Follestad (@afollestad)
- */
-class DbMigrator @Inject constructor(
- app: Application,
- private val appDb: AppDatabase
-) {
- private val legacyStore = ServerModelStore(app)
-
- fun migrateAll(): Int {
- val legacyModels = legacyStore.get()
- var count = 0
-
- for (oldModel in legacyModels) {
- // Insert site
- val site = oldModel.toNewModel()
- val siteId = appDb.siteDao()
- .insert(site)
-
- // Insert site settings
- val settingsWithId = site.settings!!.copy(
- siteId = siteId
- )
- appDb.siteSettingsDao()
- .insert(settingsWithId)
-
- // Insert validation result
- site.lastResult?.let {
- val resultWithId = it.copy(
- siteId = siteId
- )
- appDb.validationResultsDao()
- .insert(resultWithId)
- }
-
- count++
- }
-
- legacyStore.wipe()
- return count
- }
-
- private fun ServerModel.toNewModel(): Site {
- return Site(
- id = 0,
- name = this.name,
- url = this.url,
- settings = this.toSettingsModel(),
- lastResult = this.toValidationModel()
- )
- }
-
- private fun ServerModel.toSettingsModel(): SiteSettings {
- return SiteSettings(
- siteId = 0,
- validationIntervalMs = this.checkInterval,
- validationMode = this.validationMode,
- validationArgs = this.validationContent,
- disabled = this.disabled,
- networkTimeout = this.networkTimeout
- )
- }
-
- private fun ServerModel.toValidationModel(): ValidationResult? {
- if (this.lastCheck == LAST_CHECK_NONE) {
- return null
- }
- return ValidationResult(
- siteId = 0,
- timestampMs = this.lastCheck,
- status = if (this.status == CHECKING) WAITING else this.status,
- reason = this.reason
- )
- }
-}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModel.kt b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModel.kt
deleted file mode 100644
index 9882705..0000000
--- a/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModel.kt
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-@file:Suppress("DEPRECATION")
-
-package com.afollestad.nocknock.data.legacy
-
-import android.content.ContentValues
-import android.database.Cursor
-import com.afollestad.nocknock.data.model.Status
-import com.afollestad.nocknock.data.model.Status.OK
-import com.afollestad.nocknock.data.model.ValidationMode
-import com.afollestad.nocknock.data.model.toSiteStatus
-import com.afollestad.nocknock.data.model.toValidationMode
-
-const val CHECK_INTERVAL_UNSET = -1L
-const val LAST_CHECK_NONE = -1L
-
-/** @author Aidan Follestad (@afollestad)*/
-@Deprecated("Deprecated in favor of Site.")
-data class ServerModel(
- var id: Int = 0,
- val name: String,
- val url: String,
- val status: Status = OK,
- val checkInterval: Long = CHECK_INTERVAL_UNSET,
- val lastCheck: Long = LAST_CHECK_NONE,
- val reason: String? = null,
- val validationMode: ValidationMode,
- val validationContent: String? = null,
- val disabled: Boolean = false,
- val networkTimeout: Int = 0
-) {
-
- 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 COLUMN_DISABLED = "disabled"
- const val COLUMN_NETWORK_TIMEOUT = "network_timeout"
-
- const val DEFAULT_SORT_ORDER = "$COLUMN_NAME ASC, $COLUMN_DISABLED DESC"
-
- 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
- )
- ).toSiteStatus(),
- 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
- )
- ),
- disabled = cursor.getInt(
- cursor.getColumnIndex(
- COLUMN_DISABLED
- )
- ) == 1,
- networkTimeout = cursor.getInt(
- cursor.getColumnIndex(
- COLUMN_NETWORK_TIMEOUT
- )
- )
- )
- }
- }
-
- 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)
- put(COLUMN_DISABLED, disabled)
- put(COLUMN_NETWORK_TIMEOUT, networkTimeout)
- }
-}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelDbHelper.kt b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelDbHelper.kt
deleted file mode 100644
index f0bdb2f..0000000
--- a/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelDbHelper.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-@file:Suppress("DEPRECATION")
-
-package com.afollestad.nocknock.data.legacy
-
-import android.content.Context
-import android.database.sqlite.SQLiteDatabase
-import android.database.sqlite.SQLiteOpenHelper
-
-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," +
- "${ServerModel.COLUMN_DISABLED} INTEGER," +
- "${ServerModel.COLUMN_NETWORK_TIMEOUT} INTEGER" +
- ")"
-
-private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${ServerModel.TABLE_NAME}"
-
-/** @author Aidan Follestad (@afollestad) */
-@Deprecated("Use AppDatabase.")
-internal class ServerModelDbHelper(context: Context) : SQLiteOpenHelper(
- context, DATABASE_NAME, null,
- DATABASE_VERSION
-) {
- companion object {
- const val DATABASE_VERSION = 3
- 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
- ) {
- if (oldVersion < 3) {
- db.execSQL(
- "ALTER TABLE ${ServerModel.TABLE_NAME} " +
- "ADD COLUMN ${ServerModel.COLUMN_NETWORK_TIMEOUT} INTEGER DEFAULT 10000"
- )
- }
- }
-
- override fun onDowngrade(
- db: SQLiteDatabase,
- oldVersion: Int,
- newVersion: Int
- ) = onUpgrade(db, oldVersion, newVersion)
-
- fun wipe() {
- this.writableDatabase.execSQL(SQL_DELETE_ENTRIES)
- }
-}
diff --git a/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelStore.kt b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelStore.kt
deleted file mode 100644
index 2d0a0cb..0000000
--- a/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelStore.kt
+++ /dev/null
@@ -1,163 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-@file:Suppress("DEPRECATION")
-
-package com.afollestad.nocknock.data.legacy
-
-import android.app.Application
-import android.content.ContentValues
-import android.database.Cursor
-import com.afollestad.nocknock.data.legacy.ServerModel.Companion.COLUMN_ID
-import com.afollestad.nocknock.data.legacy.ServerModel.Companion.DEFAULT_SORT_ORDER
-import com.afollestad.nocknock.data.legacy.ServerModel.Companion.TABLE_NAME
-
-/** @author Aidan Follestad (@afollestad) */
-@Deprecated("Deprecated in favor of AppDatabase.")
-internal class ServerModelStore(app: Application) {
-
- private val dbHelper = ServerModelDbHelper(app)
-
- fun get(id: Int? = null): 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) }
- }
-
- 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())
- }
-
- 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)
-
- if (valuesDiff.size() == 0) {
- return 0
- }
-
- val selection = "$COLUMN_ID = ?"
- val selectionArgs = arrayOf("${model.id}")
-
- return writer.update(TABLE_NAME, valuesDiff, selection, selectionArgs)
- }
-
- fun delete(model: ServerModel) = delete(model.id)
-
- 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)
- }
-
- fun wipe() = dbHelper.wipe()
-
- private fun readModels(cursor: Cursor): List {
- val results = mutableListOf()
- while (cursor.moveToNext()) {
- results.add(ServerModel.pull(cursor))
- }
- return results
- }
-
- /**
- * Returns a [ContentValues] instance which contains only values that have changed between
- * the receiver (original) and parameter (new) instances.
- */
- private 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.
- */
- private 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/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt
index 7a461b0..31ddb04 100644
--- a/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt
+++ b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt
@@ -29,6 +29,12 @@ enum class ValidationMode(val value: Int) {
/** The site is running normally if a block of given JavaScript executes successfully. */
JAVASCRIPT(3);
+ fun toIndex() = when (this) {
+ STATUS_CODE -> 0
+ TERM_SEARCH -> 1
+ JAVASCRIPT -> 2
+ }
+
companion object {
fun fromValue(value: Int) = when (value) {
@@ -46,9 +52,3 @@ enum class ValidationMode(val value: Int) {
}
}
}
-
-fun Int.toValidationMode() =
- ValidationMode.fromValue(this)
-
-fun Int.indexToValidationMode() =
- ValidationMode.fromIndex(this)
diff --git a/dependencies.gradle b/dependencies.gradle
index 66a58f3..f1d2a5c 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -18,6 +18,7 @@ ext.versions = [
androidx : '1.0.0',
room : '2.0.0',
+ lifecycle : '2.0.0',
rxBinding : '3.0.0-alpha1',
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.java b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.java
index 3df7106..bc7599e 100644
--- a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.java
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.java
@@ -15,8 +15,8 @@
*/
package com.afollestad.nocknock.engine;
-import com.afollestad.nocknock.engine.statuscheck.RealValidationManager;
-import com.afollestad.nocknock.engine.statuscheck.ValidationManager;
+import com.afollestad.nocknock.engine.validation.RealValidationManager;
+import com.afollestad.nocknock.engine.validation.ValidationManager;
import dagger.Binds;
import dagger.Module;
import javax.inject.Singleton;
@@ -27,5 +27,5 @@ public abstract class EngineModule {
@Binds
@Singleton
- abstract ValidationManager provideCheckStatusManager(RealValidationManager checkStatusManager);
+ abstract ValidationManager provideValidationManager(RealValidationManager checkStatusManager);
}
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/BootReceiver.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt
similarity index 97%
rename from engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/BootReceiver.kt
rename to engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt
index 298d975..efdef8e 100644
--- a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/BootReceiver.kt
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.afollestad.nocknock.engine.statuscheck
+package com.afollestad.nocknock.engine.validation
import android.content.BroadcastReceiver
import android.content.Context
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationJob.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt
similarity index 99%
rename from engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationJob.kt
rename to engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt
index 307db18..69e6c54 100644
--- a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationJob.kt
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.afollestad.nocknock.engine.statuscheck
+package com.afollestad.nocknock.engine.validation
import android.app.job.JobParameters
import android.app.job.JobService
diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationManager.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt
similarity index 97%
rename from engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationManager.kt
rename to engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt
index 8fcb288..b7f129f 100644
--- a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationManager.kt
+++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.afollestad.nocknock.engine.statuscheck
+package com.afollestad.nocknock.engine.validation
import android.app.job.JobScheduler
import android.app.job.JobScheduler.RESULT_SUCCESS
@@ -23,7 +23,7 @@ import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.ERROR
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.engine.R
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_SITE_ID
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID
import com.afollestad.nocknock.utilities.providers.BundleProvider
import com.afollestad.nocknock.utilities.providers.JobInfoProvider
import com.afollestad.nocknock.utilities.providers.StringProvider
diff --git a/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt b/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt
index 071e407..db859f4 100644
--- a/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt
+++ b/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt
@@ -22,8 +22,8 @@ import com.afollestad.nocknock.data.model.Status.ERROR
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.legacy.ServerModelStore
-import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.KEY_SITE_ID
-import com.afollestad.nocknock.engine.statuscheck.RealValidationManager
+import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_SITE_ID
+import com.afollestad.nocknock.engine.validation.RealValidationManager
import com.afollestad.nocknock.utilities.providers.StringProvider
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ActivityExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
similarity index 56%
rename from utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ActivityExt.kt
rename to utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
index 9238e6c..aa46101 100644
--- a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ActivityExt.kt
+++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ViewExt.kt
@@ -15,23 +15,26 @@
*/
package com.afollestad.nocknock.utilities.ext
-import android.app.Activity
-import android.content.BroadcastReceiver
-import android.content.IntentFilter
+import android.text.Editable
+import android.text.TextWatcher
+import android.widget.EditText
-fun Activity.safeRegisterReceiver(
- broadcastReceiver: BroadcastReceiver,
- filter: IntentFilter
-) {
- try {
- registerReceiver(broadcastReceiver, filter)
- } catch (_: Exception) {
- }
-}
+fun EditText.onTextChanged(cb: (String) -> Unit) {
+ addTextChangedListener(object : TextWatcher {
+ override fun afterTextChanged(s: Editable?) = Unit
-fun Activity.safeUnregisterReceiver(broadcastReceiver: BroadcastReceiver) {
- try {
- unregisterReceiver(broadcastReceiver)
- } catch (_: Exception) {
- }
+ override fun beforeTextChanged(
+ s: CharSequence,
+ start: Int,
+ count: Int,
+ after: Int
+ ) = Unit
+
+ override fun onTextChanged(
+ s: CharSequence,
+ start: Int,
+ before: Int,
+ count: Int
+ ) = cb(s.toString().trim())
+ })
}
diff --git a/viewcomponents/build.gradle b/viewcomponents/build.gradle
index 3c1aa2e..51bd417 100644
--- a/viewcomponents/build.gradle
+++ b/viewcomponents/build.gradle
@@ -19,6 +19,7 @@ dependencies {
implementation project(':data')
implementation 'androidx.appcompat:appcompat:' + versions.androidx
+ api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle
api 'com.squareup.okhttp3:okhttp:' + versions.okHttp
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/CheckIntervalLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/CheckIntervalLayout.kt
deleted file mode 100644
index 2a7ccfe..0000000
--- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/CheckIntervalLayout.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.afollestad.nocknock.viewcomponents
-
-import android.content.Context
-import android.util.AttributeSet
-import android.widget.ArrayAdapter
-import android.widget.LinearLayout
-import androidx.annotation.CheckResult
-import com.afollestad.nocknock.utilities.ext.DAY
-import com.afollestad.nocknock.utilities.ext.HOUR
-import com.afollestad.nocknock.utilities.ext.MINUTE
-import com.afollestad.nocknock.utilities.ext.WEEK
-import com.afollestad.nocknock.viewcomponents.R.array
-import com.afollestad.nocknock.viewcomponents.ext.textAsLong
-import kotlinx.android.synthetic.main.check_interval_layout.view.input
-import kotlinx.android.synthetic.main.check_interval_layout.view.spinner
-import kotlin.math.ceil
-
-/** @author Aidan Follestad (@afollestad) */
-class CheckIntervalLayout(
- context: Context,
- attrs: AttributeSet? = null
-) : LinearLayout(context, attrs) {
-
- companion object {
- private const val INDEX_MINUTE = 0
- private const val INDEX_HOUR = 1
- private const val INDEX_DAY = 2
- private const val INDEX_WEEK = 3
- }
-
- init {
- orientation = VERTICAL
- inflate(context, R.layout.check_interval_layout, this)
- }
-
- override fun onFinishInflate() {
- super.onFinishInflate()
- val spinnerAdapter = ArrayAdapter(
- context,
- R.layout.list_item_spinner,
- resources.getStringArray(array.interval_options)
- )
- spinnerAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
- spinner.adapter = spinnerAdapter
- }
-
- fun setError(error: String?) {
- input.error = error
- }
-
- fun set(interval: Long) {
- when {
- interval >= WEEK -> {
- input.setText(calculateDisplayValue(interval, WEEK))
- spinner.setSelection(3)
- }
- interval >= DAY -> {
- input.setText(calculateDisplayValue(interval, DAY))
- spinner.setSelection(2)
- }
- interval >= HOUR -> {
- input.setText(calculateDisplayValue(interval, HOUR))
- spinner.setSelection(1)
- }
- interval >= MINUTE -> {
- input.setText(calculateDisplayValue(interval, MINUTE))
- spinner.setSelection(0)
- }
- else -> {
- input.setText("0")
- spinner.setSelection(0)
- }
- }
- }
-
- @CheckResult fun getSelectedCheckInterval(): Long {
- val intervalInput = input.textAsLong()
- val spinnerPos = spinner.selectedItemPosition
- return when (spinnerPos) {
- INDEX_MINUTE -> intervalInput * MINUTE
- INDEX_HOUR -> intervalInput * HOUR
- INDEX_DAY -> intervalInput * DAY
- INDEX_WEEK -> intervalInput * WEEK
- else -> throw IllegalStateException("Unexpected index: $spinnerPos")
- }
- }
-
- private fun calculateDisplayValue(
- interval: Long,
- by: Long
- ): String {
- val intervalFloat = interval.toFloat()
- val byFloat = by.toFloat()
- return ceil(intervalFloat / byFloat).toInt()
- .toString()
- }
-}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/JavaScriptInputLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/JavaScriptInputLayout.kt
deleted file mode 100644
index 2636008..0000000
--- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/JavaScriptInputLayout.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.afollestad.nocknock.viewcomponents
-
-import android.content.Context
-import android.util.AttributeSet
-import android.widget.HorizontalScrollView
-import androidx.annotation.CheckResult
-import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
-import com.afollestad.nocknock.viewcomponents.ext.dimenInt
-import com.afollestad.nocknock.viewcomponents.ext.showOrHide
-import com.afollestad.nocknock.viewcomponents.ext.trimmedText
-import kotlinx.android.synthetic.main.javascript_input_layout.view.error_text
-import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput
-
-/** @author Aidan Follestad (@afollestad) */
-class JavaScriptInputLayout(
- context: Context,
- attrs: AttributeSet? = null
-) : HorizontalScrollView(context, attrs) {
-
- init {
- val contentInset = dimenInt(R.dimen.content_inset)
- val contentInsetHalf = dimenInt(R.dimen.content_inset_half)
- setPadding(
- contentInsetHalf, // left
- contentInset, // top
- contentInsetHalf, // right
- contentInset // bottom
- )
- elevation = dimenFloat(R.dimen.default_elevation)
- inflate(context, R.layout.javascript_input_layout, this)
- }
-
- fun setError(error: String?) {
- error_text.showOrHide(error != null)
- error_text.text = error
- }
-
- fun setCode(code: String?) {
- if (code.isNullOrEmpty()) {
- setDefaultCode()
- return
- }
- userInput.setText(code.trim())
- }
-
- @CheckResult fun getCode() = userInput.trimmedText()
-
- fun clear() = userInput.setText("")
-
- private fun setDefaultCode() = userInput.setText(R.string.default_js)
-}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt
index 5596d4c..a91b11c 100644
--- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt
@@ -20,6 +20,9 @@ import android.os.Handler
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
import com.afollestad.nocknock.viewcomponents.ext.hide
import com.afollestad.nocknock.viewcomponents.ext.show
@@ -43,12 +46,19 @@ class LoadingIndicatorFrame(
isFocusable = true
}
- fun setLoading() {
- delayHandler.postDelayed(showRunnable, SHOW_DELAY_MS)
+ fun setIsLoading(isLoading: Boolean) {
+ delayHandler.removeCallbacks(showRunnable)
+ if (isLoading) {
+ delayHandler.postDelayed(showRunnable, SHOW_DELAY_MS)
+ } else {
+ hide()
+ }
}
- fun setDone() {
- delayHandler.removeCallbacks(showRunnable)
- hide()
- }
+ fun observe(
+ owner: LifecycleOwner,
+ data: LiveData
+ ) = data.observe(owner, Observer {
+ setIsLoading(it)
+ })
}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/IntExt.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/IntExt.kt
new file mode 100644
index 0000000..6b3c4c1
--- /dev/null
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/IntExt.kt
@@ -0,0 +1,20 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.viewcomponents.ext
+
+fun Int?.isNullOrLessThan(than: Int): Boolean {
+ return this == null || this < than
+}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/LiveDataExt.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/LiveDataExt.kt
new file mode 100644
index 0000000..7b73308
--- /dev/null
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/LiveDataExt.kt
@@ -0,0 +1,148 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.viewcomponents.ext
+
+import android.view.View
+import android.widget.EditText
+import android.widget.Spinner
+import android.widget.TextView
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import androidx.lifecycle.Transformations
+import com.afollestad.nocknock.utilities.ext.onTextChanged
+
+fun LiveData.map(mapper: (X) -> Y) =
+ Transformations.map(this, mapper)!!
+
+fun LiveData.switchMap(mapper: (X) -> LiveData) =
+ Transformations.switchMap(this, mapper)!!
+
+inline fun EditText.attachLiveData(
+ lifecycleOwner: LifecycleOwner,
+ data: MutableLiveData
+) {
+ // Out
+ when {
+ T::class == Int::class -> {
+ onTextChanged { data.postValue(it.trim().toInt() as T) }
+ }
+ T::class == Long::class -> {
+ onTextChanged { data.postValue(it.trim().toLong() as T) }
+ }
+ T::class == String::class -> {
+ onTextChanged { data.postValue(it.trim() as T) }
+ }
+ else -> {
+ throw IllegalArgumentException("Can't send EditText text changes into ${T::class}")
+ }
+ }
+ // In
+ data.observe(lifecycleOwner, Observer {
+ when {
+ T::class == Int::class -> setText(it as Int)
+ T::class == String::class -> setText(it as String)
+ }
+ })
+}
+
+fun Spinner.attachLiveData(
+ lifecycleOwner: LifecycleOwner,
+ data: MutableLiveData,
+ outTransformer: (Int) -> T,
+ inTransformer: (T) -> Int
+) {
+ // Out
+ onItemSelected { data.postValue(outTransformer(it)) }
+ // In
+ data.observe(lifecycleOwner, Observer {
+ setSelection(inTransformer(it))
+ })
+}
+
+fun LiveData.toViewError(
+ owner: LifecycleOwner,
+ view: EditText
+) = observe(owner, Observer { error ->
+ view.error = if (error != null) {
+ view.resources.getString(error)
+ } else {
+ null
+ }
+})
+
+inline fun LiveData.toViewText(
+ owner: LifecycleOwner,
+ view: TextView
+) = observe(owner, Observer {
+ when {
+ T::class == Int::class -> view.setText(it as Int)
+ T::class == String::class -> view.text = it as String
+ else -> throw IllegalStateException("Cannot set ${T::class} as view text.")
+ }
+})
+
+fun LiveData.toViewVisibility(
+ owner: LifecycleOwner,
+ view: View
+) = observe(owner, Observer { view.showOrHide(it) })
+
+/** @author Aidan Follestad (@afollestad) */
+class ZipLiveData(
+ source1: LiveData,
+ source2: LiveData
+) : MediatorLiveData>() {
+
+ private var data1: T? = null
+ private var data2: K? = null
+
+ init {
+ super.addSource(source1) {
+ data1 = it
+ maybeNotify()
+ }
+ super.addSource(source2) {
+ data2 = it
+ maybeNotify()
+ }
+ }
+
+ private fun maybeNotify() {
+ if (data1 != null && data2 != null) {
+ value = Pair(data1!!, data2!!)
+ }
+ }
+
+ override fun addSource(
+ source: LiveData,
+ onChanged: Observer
+ ) {
+ throw UnsupportedOperationException()
+ }
+
+ override fun removeSource(toRemote: LiveData) {
+ throw UnsupportedOperationException()
+ }
+}
+
+fun zip(
+ source1: LiveData,
+ source2: LiveData
+): MediatorLiveData> {
+ return ZipLiveData(source1, source2)
+}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/TextViewExt.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/TextViewExt.kt
deleted file mode 100644
index 2d5576e..0000000
--- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/ext/TextViewExt.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Designed and developed by Aidan Follestad (@afollestad)
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.afollestad.nocknock.viewcomponents.ext
-
-import android.widget.TextView
-
-fun TextView.trimmedText() = text.toString().trim()
-
-fun TextView.textAsInt(defaultValue: Int = 0): Int {
- val text = trimmedText()
- return if (text.isEmpty()) defaultValue else text.toInt()
-}
-
-fun TextView.textAsLong(defaultValue: Long = 0L): Long {
- val text = trimmedText()
- return if (text.isEmpty()) defaultValue else text.toLong()
-}
-
-///** @author https://stackoverflow.com/a/53296137/309644 */
-//fun EditText.addFilter(filter: InputFilter) {
-// filters =
-// if (filters.isNullOrEmpty()) {
-// arrayOf(filter)
-// } else {
-// filters
-// .toMutableList()
-// .apply {
-// removeAll { it.javaClass == filter.javaClass }
-// add(filter)
-// }
-// .toTypedArray()
-// }
-//}
-//
-//fun EditText.removeFilters(type: Class) {
-// filters =
-// if (filters.isNullOrEmpty()) {
-// filters
-// } else {
-// filters
-// .toMutableList()
-// .apply {
-// removeAll { it.javaClass == type }
-// }
-// .toTypedArray()
-// }
-//}
-//
-//fun EditText.setMaxLength(maxLength: Int) {
-// if (maxLength <= 0) {
-// removeFilters(LengthFilter::class.java)
-// } else {
-// if (text.length > maxLength) {
-// setText(text.subSequence(0, maxLength))
-// setSelection(text.length)
-// }
-// addFilter(LengthFilter(maxLength))
-// }
-//}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/CheckIntervalLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/CheckIntervalLayout.kt
new file mode 100644
index 0000000..819f45d
--- /dev/null
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/CheckIntervalLayout.kt
@@ -0,0 +1,133 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.viewcomponents.interval
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.ArrayAdapter
+import android.widget.LinearLayout
+import androidx.lifecycle.Lifecycle.State.DESTROYED
+import androidx.lifecycle.Lifecycle.State.RESUMED
+import androidx.lifecycle.Lifecycle.State.STARTED
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+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.onTextChanged
+import com.afollestad.nocknock.viewcomponents.R.array
+import com.afollestad.nocknock.viewcomponents.R.layout
+import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
+import kotlinx.android.synthetic.main.check_interval_layout.view.input
+import kotlinx.android.synthetic.main.check_interval_layout.view.spinner
+
+/** @author Aidan Follestad (@afollestad) */
+class CheckIntervalLayout(
+ context: Context,
+ attrs: AttributeSet? = null
+) : LinearLayout(context, attrs), LifecycleOwner {
+
+ companion object {
+ const val INDEX_MINUTE = 0
+ const val INDEX_HOUR = 1
+ const val INDEX_DAY = 2
+ const val INDEX_WEEK = 3
+ }
+
+ init {
+ orientation = VERTICAL
+ inflate(context, layout.check_interval_layout, this)
+ }
+
+ private lateinit var valueData: MutableLiveData
+ private lateinit var multiplierData: MutableLiveData
+
+ private val lifecycle = LifecycleRegistry(this)
+
+ override fun onFinishInflate() {
+ super.onFinishInflate()
+ val spinnerAdapter = ArrayAdapter(
+ context,
+ layout.list_item_spinner,
+ resources.getStringArray(array.interval_options)
+ )
+ spinnerAdapter.setDropDownViewResource(
+ layout.list_item_spinner_dropdown
+ )
+ spinner.adapter = spinnerAdapter
+ lifecycle.markState(STARTED)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ lifecycle.markState(RESUMED)
+ }
+
+ override fun onDetachedFromWindow() {
+ lifecycle.markState(DESTROYED)
+ super.onDetachedFromWindow()
+ }
+
+ fun setError(error: String?) {
+ input.error = error
+ }
+
+ fun attach(
+ valueData: MutableLiveData,
+ multiplierData: MutableLiveData,
+ errorData: LiveData
+ ) {
+ this.valueData = valueData
+ this.multiplierData = multiplierData
+
+ this.valueData.observe(this, Observer {
+ input.setText("$it")
+ })
+ this.multiplierData.observe(this, Observer { multiplier ->
+ val targetPos = when (multiplier) {
+ MINUTE -> 0
+ HOUR -> 1
+ DAY -> 2
+ WEEK -> 3
+ else -> throw IllegalStateException("Unknown multiplier: $multiplier")
+ }
+ if (spinner.selectedItemPosition != targetPos) {
+ spinner.setSelection(targetPos)
+ }
+ })
+
+ errorData.observe(this, Observer {
+ setError(if (it != null) resources.getString(it) else null)
+ })
+
+ input.onTextChanged { this.valueData.value = it.toInt() }
+ spinner.onItemSelected {
+ this.multiplierData.value = when (it) {
+ INDEX_MINUTE -> MINUTE
+ INDEX_HOUR -> HOUR
+ INDEX_DAY -> DAY
+ INDEX_WEEK -> WEEK
+ else -> throw IllegalStateException("Unknown multiplier index: $it")
+ }
+ }
+ }
+
+ override fun getLifecycle() = lifecycle
+}
diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt
new file mode 100644
index 0000000..3c91995
--- /dev/null
+++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/js/JavaScriptInputLayout.kt
@@ -0,0 +1,111 @@
+/**
+ * Designed and developed by Aidan Follestad (@afollestad)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.afollestad.nocknock.viewcomponents.js
+
+import android.content.Context
+import android.util.AttributeSet
+import android.widget.HorizontalScrollView
+import androidx.lifecycle.Lifecycle.State.CREATED
+import androidx.lifecycle.Lifecycle.State.DESTROYED
+import androidx.lifecycle.Lifecycle.State.RESUMED
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import com.afollestad.nocknock.utilities.ext.onTextChanged
+import com.afollestad.nocknock.viewcomponents.R.dimen
+import com.afollestad.nocknock.viewcomponents.R.layout
+import com.afollestad.nocknock.viewcomponents.R.string
+import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
+import com.afollestad.nocknock.viewcomponents.ext.dimenInt
+import com.afollestad.nocknock.viewcomponents.ext.showOrHide
+import com.afollestad.nocknock.viewcomponents.ext.toViewVisibility
+import kotlinx.android.synthetic.main.javascript_input_layout.view.error_text
+import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput
+
+/** @author Aidan Follestad (@afollestad) */
+class JavaScriptInputLayout(
+ context: Context,
+ attrs: AttributeSet? = null
+) : HorizontalScrollView(context, attrs), LifecycleOwner {
+
+ private val lifecycle = LifecycleRegistry(this)
+ private lateinit var codeData: MutableLiveData
+
+ init {
+ val contentInset = dimenInt(dimen.content_inset)
+ val contentInsetHalf = dimenInt(
+ dimen.content_inset_half
+ )
+ setPadding(
+ contentInsetHalf, // left
+ contentInset, // top
+ contentInsetHalf, // right
+ contentInset // bottom
+ )
+ elevation = dimenFloat(dimen.default_elevation)
+ inflate(context, layout.javascript_input_layout, this)
+ }
+
+ override fun onFinishInflate() {
+ super.onFinishInflate()
+ lifecycle.markState(CREATED)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ lifecycle.markState(RESUMED)
+ }
+
+ override fun onDetachedFromWindow() {
+ lifecycle.markState(DESTROYED)
+ super.onDetachedFromWindow()
+ }
+
+ fun setError(error: String?) {
+ error_text.showOrHide(error != null)
+ error_text.text = error
+ }
+
+ fun attach(
+ codeData: MutableLiveData,
+ errorData: LiveData,
+ visibility: LiveData
+ ) {
+ this.codeData = codeData
+ this.codeData.observe(this, Observer {
+ if (it.isNullOrEmpty()) {
+ setDefaultCode()
+ } else {
+ userInput.setText(it)
+ }
+ })
+ errorData.observe(this, Observer {
+ setError(if (it != null) resources.getString(it) else null)
+ })
+ visibility.toViewVisibility(this, this)
+ userInput.onTextChanged { this.codeData.value = it }
+ }
+
+ fun clear() = userInput.setText("")
+
+ private fun setDefaultCode() = userInput.setText(
+ string.default_js
+ )
+
+ override fun getLifecycle() = lifecycle
+}