mirror of
https://github.com/afollestad/nock-nock.git
synced 2025-04-20 03:25:14 +00:00
Initial implementation of a presenter-less app, just using view models.
This commit is contained in:
parent
88ae30c0c9
commit
c9750f5f66
58 changed files with 2271 additions and 2800 deletions
|
@ -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
|
||||
|
|
|
@ -42,11 +42,11 @@
|
|||
android:windowSoftInputMode="stateHidden"/>
|
||||
|
||||
<service
|
||||
android:name=".engine.statuscheck.ValidationJob"
|
||||
android:name=".engine.validation.ValidationJob"
|
||||
android:label="@string/check_service_name"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE"/>
|
||||
|
||||
<receiver android:name=".engine.statuscheck.BootReceiver">
|
||||
<receiver android:name=".engine.validation.BootReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<SiteViewHolder>() {
|
||||
|
||||
private val models = mutableListOf<Site>()
|
||||
private var models = mutableListOf<Site>()
|
||||
|
||||
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<Site>) {
|
||||
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(
|
||||
|
|
|
@ -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<Site>,
|
||||
private val newItems: List<Site>
|
||||
) : 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]
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Site>)
|
||||
|
||||
fun updateModel(model: Site)
|
||||
|
||||
fun onSiteDeleted(model: Site)
|
||||
|
||||
fun scopeWhileAttached(
|
||||
context: CoroutineContext,
|
||||
exec: ScopeReceiver
|
||||
)
|
||||
}
|
||||
@Qualifier
|
||||
@Retention(RUNTIME)
|
||||
annotation class IoDispatcher
|
|
@ -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
|
|
@ -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()
|
||||
}
|
|
@ -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<Class<out ViewModel>, Provider<ViewModel>>
|
||||
|
||||
@Target(FUNCTION, PROPERTY_GETTER, PROPERTY_SETTER)
|
||||
@Retention(RUNTIME)
|
||||
@MapKey
|
||||
annotation class ViewModelKey(val value: KClass<out ViewModel>)
|
||||
|
||||
/**
|
||||
* https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455
|
||||
*/
|
||||
@Singleton
|
||||
class ViewModelFactory @Inject constructor(private val viewModels: ViewModelMap) :
|
||||
ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return viewModels[modelClass]?.get() as T
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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<Int>()
|
||||
var revealCy by notNull<Int>()
|
||||
var revealRadius by notNull<Float>()
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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<String>()
|
||||
val url = MutableLiveData<String>()
|
||||
val timeout = MutableLiveData<Int>()
|
||||
val validationMode = MutableLiveData<ValidationMode>()
|
||||
val validationSearchTerm = MutableLiveData<String>()
|
||||
val validationScript = MutableLiveData<String>()
|
||||
val checkIntervalValue = MutableLiveData<Int>()
|
||||
val checkIntervalUnit = MutableLiveData<Long>()
|
||||
|
||||
// Private properties
|
||||
private val isLoading = MutableLiveData<Boolean>()
|
||||
private val nameError = MutableLiveData<Int?>()
|
||||
private val urlError = MutableLiveData<Int?>()
|
||||
private val timeoutError = MutableLiveData<Int?>()
|
||||
private val validationSearchTermError = MutableLiveData<Int?>()
|
||||
private val validationScriptError = MutableLiveData<Int?>()
|
||||
private val checkIntervalValueError = MutableLiveData<Int?>()
|
||||
|
||||
// Expose private properties or calculated properties
|
||||
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
|
||||
|
||||
@CheckResult fun onNameError(): LiveData<Int?> = nameError
|
||||
|
||||
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
|
||||
|
||||
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
|
||||
return url.map {
|
||||
val parsed = HttpUrl.parse(it)
|
||||
return@map it.isNotEmpty() &&
|
||||
parsed != null &&
|
||||
parsed.scheme() != "http" &&
|
||||
parsed.scheme() != "https"
|
||||
}
|
||||
}
|
||||
|
||||
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
|
||||
|
||||
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
|
||||
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<Int?> = validationSearchTermError
|
||||
|
||||
@CheckResult fun onValidationSearchTermVisibility() =
|
||||
validationMode.map { it == TERM_SEARCH }
|
||||
|
||||
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
|
||||
|
||||
@CheckResult fun onValidationScriptVisibility() =
|
||||
validationMode.map { it == JAVASCRIPT }
|
||||
|
||||
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
|
||||
|
||||
// Actions
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<Site>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<List<Site>>()
|
||||
private val isLoading = MutableLiveData<Boolean>()
|
||||
|
||||
@CheckResult fun onSites(): LiveData<List<Site>> = sites
|
||||
|
||||
@CheckResult fun onIsLoading(): LiveData<Boolean> = 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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<Status>()
|
||||
val name = MutableLiveData<String>()
|
||||
val url = MutableLiveData<String>()
|
||||
val timeout = MutableLiveData<Int>()
|
||||
val validationMode = MutableLiveData<ValidationMode>()
|
||||
val validationSearchTerm = MutableLiveData<String>()
|
||||
val validationScript = MutableLiveData<String>()
|
||||
val checkIntervalValue = MutableLiveData<Int>()
|
||||
val checkIntervalUnit = MutableLiveData<Long>()
|
||||
internal val disabled = MutableLiveData<Boolean>()
|
||||
internal val lastResult = MutableLiveData<ValidationResult?>()
|
||||
|
||||
// Private properties
|
||||
private val isLoading = MutableLiveData<Boolean>()
|
||||
private val nameError = MutableLiveData<Int?>()
|
||||
private val urlError = MutableLiveData<Int?>()
|
||||
private val timeoutError = MutableLiveData<Int?>()
|
||||
private val validationSearchTermError = MutableLiveData<Int?>()
|
||||
private val validationScriptError = MutableLiveData<Int?>()
|
||||
private val checkIntervalValueError = MutableLiveData<Int?>()
|
||||
|
||||
// Expose private properties or calculated properties
|
||||
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
|
||||
|
||||
@CheckResult fun onNameError(): LiveData<Int?> = nameError
|
||||
|
||||
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
|
||||
|
||||
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
|
||||
return url.map {
|
||||
val parsed = HttpUrl.parse(it)
|
||||
return@map it.isNotEmpty() &&
|
||||
parsed != null &&
|
||||
parsed.scheme() != "http" &&
|
||||
parsed.scheme() != "https"
|
||||
}
|
||||
}
|
||||
|
||||
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
|
||||
|
||||
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
|
||||
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<Int?> = validationSearchTermError
|
||||
|
||||
@CheckResult fun onValidationSearchTermVisibility() =
|
||||
validationMode.map { it == TERM_SEARCH }
|
||||
|
||||
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
|
||||
|
||||
@CheckResult fun onValidationScriptVisibility() =
|
||||
validationMode.map { it == JAVASCRIPT }
|
||||
|
||||
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
|
||||
|
||||
@CheckResult fun onDisableChecksVisibility(): LiveData<Boolean> =
|
||||
disabled.map { !it }
|
||||
|
||||
@CheckResult fun onDoneButtonText(): LiveData<Int> =
|
||||
disabled.map {
|
||||
if (it) R.string.renable_and_save_changes
|
||||
else R.string.save_changes
|
||||
}
|
||||
|
||||
@CheckResult fun onLastCheckResultText(): LiveData<String> = 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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
|
||||
<include layout="@layout/include_divider"/>
|
||||
|
||||
<com.afollestad.nocknock.viewcomponents.CheckIntervalLayout
|
||||
<com.afollestad.nocknock.viewcomponents.interval.CheckIntervalLayout
|
||||
android:id="@+id/checkIntervalLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -151,7 +151,7 @@
|
|||
style="@style/NockText.Body.Light"
|
||||
/>
|
||||
|
||||
<com.afollestad.nocknock.viewcomponents.JavaScriptInputLayout
|
||||
<com.afollestad.nocknock.viewcomponents.js.JavaScriptInputLayout
|
||||
android:id="@+id/scriptInputLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -115,7 +115,7 @@
|
|||
android:layout_marginTop="@dimen/content_inset_less"
|
||||
/>
|
||||
|
||||
<com.afollestad.nocknock.viewcomponents.CheckIntervalLayout
|
||||
<com.afollestad.nocknock.viewcomponents.interval.CheckIntervalLayout
|
||||
android:id="@+id/checkIntervalLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -181,7 +181,7 @@
|
|||
style="@style/NockText.Body.Light"
|
||||
/>
|
||||
|
||||
<com.afollestad.nocknock.viewcomponents.JavaScriptInputLayout
|
||||
<com.afollestad.nocknock.viewcomponents.js.JavaScriptInputLayout
|
||||
android:id="@+id/scriptInputLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -1,285 +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 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.statuscheck.ValidationManager
|
||||
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<ValidationManager>()
|
||||
private val view = mock<AddSiteView>()
|
||||
|
||||
private val presenter = RealAddSitePresenter(
|
||||
database,
|
||||
checkStatusManager
|
||||
)
|
||||
|
||||
@Before fun setup() {
|
||||
doAnswer {
|
||||
val exec = it.getArgument<ScopeReceiver>(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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<Site>()
|
||||
val settingsCaptor = argumentCaptor<SiteSettings>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -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<T>(data: LiveData<T>) {
|
||||
|
||||
private val receivedValues = mutableListOf<T>()
|
||||
private val observer = Observer<T> { 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<T> = receivedValues
|
||||
}
|
||||
|
||||
@CheckResult fun <T> LiveData<T>.test() = TestLiveData(this)
|
|
@ -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<SharedPreferences> {
|
||||
on { getBoolean("did_db_migration", false) } doReturn true
|
||||
}
|
||||
private val app = mock<Application> {
|
||||
on { getSharedPreferences("settings", MODE_PRIVATE) } doReturn prefs
|
||||
}
|
||||
|
||||
private val database = mockDatabase()
|
||||
|
||||
private val notificationManager = mock<NockNotificationManager>()
|
||||
private val checkStatusManager = mock<ValidationManager>()
|
||||
private val view = mock<MainView>()
|
||||
|
||||
private val presenter = RealMainPresenter(
|
||||
app,
|
||||
database,
|
||||
notificationManager,
|
||||
checkStatusManager
|
||||
)
|
||||
|
||||
@Before fun setup() {
|
||||
doAnswer {
|
||||
val exec = it.getArgument<ScopeReceiver>(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<List<Site>>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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<ValidationManager>()
|
||||
private val notificationManager = mock<NockNotificationManager>()
|
||||
private val view = mock<ViewSiteView>()
|
||||
|
||||
private val presenter = RealViewSitePresenter(
|
||||
database,
|
||||
checkStatusManager,
|
||||
notificationManager
|
||||
)
|
||||
|
||||
@Before fun setup() {
|
||||
doAnswer {
|
||||
val exec = it.getArgument<ScopeReceiver>(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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<InputErrors>()
|
||||
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<Site>()
|
||||
val settingsCaptor = argumentCaptor<SiteSettings>()
|
||||
val resultCaptor = argumentCaptor<ValidationResult>()
|
||||
|
||||
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<Site>()
|
||||
val settingsCaptor = argumentCaptor<SiteSettings>()
|
||||
val resultCaptor = argumentCaptor<ValidationResult>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Application>()
|
||||
private val callback = mock<SiteCallback>()
|
||||
|
||||
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<IntentFilter>()
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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<ValidationManager>()
|
||||
// private val view = mock<AddSiteView>()
|
||||
//
|
||||
// private val presenter = RealAddSitePresenter(
|
||||
// database,
|
||||
// checkStatusManager
|
||||
// )
|
||||
//
|
||||
// @Before fun setup() {
|
||||
// doAnswer {
|
||||
// val exec = it.getArgument<ScopeReceiver>(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<InputErrors>()
|
||||
// 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<InputErrors>()
|
||||
// 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<InputErrors>()
|
||||
// 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<InputErrors>()
|
||||
// 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<InputErrors>()
|
||||
// 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<InputErrors>()
|
||||
// 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<InputErrors>()
|
||||
// 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<Site>()
|
||||
// val settingsCaptor = argumentCaptor<SiteSettings>()
|
||||
//
|
||||
// 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()
|
||||
// }
|
||||
//}
|
|
@ -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<NockNotificationManager>()
|
||||
private val validationManager = mock<ValidationManager>()
|
||||
|
||||
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!!)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<ServerModel> {
|
||||
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<ServerModel> {
|
||||
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<ServerModel> {
|
||||
val results = mutableListOf<ServerModel>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -18,6 +18,7 @@ ext.versions = [
|
|||
|
||||
androidx : '1.0.0',
|
||||
room : '2.0.0',
|
||||
lifecycle : '2.0.0',
|
||||
|
||||
rxBinding : '3.0.0-alpha1',
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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<Boolean>
|
||||
) = data.observe(owner, Observer {
|
||||
setIsLoading(it)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 <X, Y> LiveData<X>.map(mapper: (X) -> Y) =
|
||||
Transformations.map(this, mapper)!!
|
||||
|
||||
fun <X, Y> LiveData<X>.switchMap(mapper: (X) -> LiveData<Y>) =
|
||||
Transformations.switchMap(this, mapper)!!
|
||||
|
||||
inline fun <reified T> EditText.attachLiveData(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
data: MutableLiveData<T>
|
||||
) {
|
||||
// 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 <T> Spinner.attachLiveData(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
data: MutableLiveData<T>,
|
||||
outTransformer: (Int) -> T,
|
||||
inTransformer: (T) -> Int
|
||||
) {
|
||||
// Out
|
||||
onItemSelected { data.postValue(outTransformer(it)) }
|
||||
// In
|
||||
data.observe(lifecycleOwner, Observer {
|
||||
setSelection(inTransformer(it))
|
||||
})
|
||||
}
|
||||
|
||||
fun LiveData<Int?>.toViewError(
|
||||
owner: LifecycleOwner,
|
||||
view: EditText
|
||||
) = observe(owner, Observer { error ->
|
||||
view.error = if (error != null) {
|
||||
view.resources.getString(error)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
})
|
||||
|
||||
inline fun <reified T> LiveData<T>.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<Boolean>.toViewVisibility(
|
||||
owner: LifecycleOwner,
|
||||
view: View
|
||||
) = observe(owner, Observer { view.showOrHide(it) })
|
||||
|
||||
/** @author Aidan Follestad (@afollestad) */
|
||||
class ZipLiveData<T, K>(
|
||||
source1: LiveData<T>,
|
||||
source2: LiveData<K>
|
||||
) : MediatorLiveData<Pair<T, K>>() {
|
||||
|
||||
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 <S : Any?> addSource(
|
||||
source: LiveData<S>,
|
||||
onChanged: Observer<in S>
|
||||
) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun <T : Any?> removeSource(toRemote: LiveData<T>) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T, K> zip(
|
||||
source1: LiveData<T>,
|
||||
source2: LiveData<K>
|
||||
): MediatorLiveData<Pair<T, K>> {
|
||||
return ZipLiveData(source1, source2)
|
||||
}
|
|
@ -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 <T : InputFilter> EditText.removeFilters(type: Class<in T>) {
|
||||
// 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))
|
||||
// }
|
||||
//}
|
|
@ -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<Int>
|
||||
private lateinit var multiplierData: MutableLiveData<Long>
|
||||
|
||||
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<Int>,
|
||||
multiplierData: MutableLiveData<Long>,
|
||||
errorData: LiveData<Int?>
|
||||
) {
|
||||
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
|
||||
}
|
|
@ -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<String>
|
||||
|
||||
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<String>,
|
||||
errorData: LiveData<Int?>,
|
||||
visibility: LiveData<Boolean>
|
||||
) {
|
||||
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
|
||||
}
|
Loading…
Add table
Reference in a new issue