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