diff --git a/app/build.gradle b/app/build.gradle index aad5e53..0a8d678 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,9 +18,9 @@ android { } dependencies { - implementation project(':data') implementation project(':utilities') implementation project(':engine') + implementation project(':data') implementation project(':notifications') implementation project(':viewcomponents') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 87a00d8..c0b8ee5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ 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 c1cf284..492ce0e 100644 --- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt +++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt @@ -19,7 +19,7 @@ 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.CheckStatusJob +import com.afollestad.nocknock.engine.statuscheck.ValidationJob import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.ui.addsite.AddSiteActivity import com.afollestad.nocknock.ui.main.MainActivity @@ -82,7 +82,7 @@ class NockNockApp : Application(), Injector { is MainActivity -> appComponent.inject(target) is ViewSiteActivity -> appComponent.inject(target) is AddSiteActivity -> appComponent.inject(target) - is CheckStatusJob -> appComponent.inject(target) + is ValidationJob -> appComponent.inject(target) is BootReceiver -> appComponent.inject(target) else -> throw IllegalStateException("Can't inject into $target") } diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt similarity index 74% rename from app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt rename to app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt index c87760e..a519e5a 100644 --- a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt +++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt @@ -20,9 +20,10 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.isPending -import com.afollestad.nocknock.data.textRes +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.Status.WAITING +import com.afollestad.nocknock.data.model.isPending +import com.afollestad.nocknock.data.model.textRes import com.afollestad.nocknock.utilities.ui.onDebouncedClick import kotlinx.android.synthetic.main.list_item_server.view.iconStatus import kotlinx.android.synthetic.main.list_item_server.view.textInterval @@ -30,10 +31,10 @@ import kotlinx.android.synthetic.main.list_item_server.view.textName import kotlinx.android.synthetic.main.list_item_server.view.textStatus import kotlinx.android.synthetic.main.list_item_server.view.textUrl -typealias Listener = (model: ServerModel, longClick: Boolean) -> Unit +typealias Listener = (model: Site, longClick: Boolean) -> Unit /** @author Aidan Follestad (@afollestad) */ -class ServerVH constructor( +class SiteViewHolder constructor( itemView: View, private val adapter: ServerAdapter ) : RecyclerView.ViewHolder(itemView), View.OnLongClickListener { @@ -45,24 +46,32 @@ class ServerVH constructor( itemView.setOnLongClickListener(this) } - fun bind(model: ServerModel) { + fun bind(model: Site) { + requireNotNull(model.settings) { "Settings must be populated." } + itemView.textName.text = model.name itemView.textUrl.text = model.url - itemView.iconStatus.setStatus(model.status) - val statusText = model.status.textRes() - if (statusText == 0) { - itemView.textStatus.text = model.reason + val lastResult = model.lastResult + if (lastResult != null) { + itemView.iconStatus.setStatus(lastResult.status) + val statusText = lastResult.status.textRes() + if (statusText == 0) { + itemView.textStatus.text = lastResult.reason + } else { + itemView.textStatus.setText(statusText) + } } else { - itemView.textStatus.setText(statusText) + itemView.iconStatus.setStatus(WAITING) + itemView.textStatus.setText(R.string.none) } val res = itemView.resources when { - model.disabled -> { + model.settings?.disabled == true -> { itemView.textInterval.setText(R.string.checks_disabled) } - model.status.isPending() -> { + model.lastResult?.status.isPending() -> { itemView.textInterval.text = res.getString( R.string.next_check_x, res.getString(R.string.now) @@ -84,21 +93,21 @@ class ServerVH constructor( } /** @author Aidan Follestad (@afollestad) */ -class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter() { +class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter() { - private val models = mutableListOf() + private val models = mutableListOf() internal fun performClick( index: Int, longClick: Boolean ) = listener.invoke(models[index], longClick) - fun add(model: ServerModel) { + fun add(model: Site) { models.add(model) notifyItemInserted(models.size - 1) } - fun update(target: ServerModel) { + fun update(target: Site) { for ((i, model) in models.withIndex()) { if (model.id == target.id) { update(i, target) @@ -109,7 +118,7 @@ class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter) { + fun set(newModels: List) { this.models.clear() if (!newModels.isEmpty()) { this.models.addAll(newModels) @@ -140,14 +149,14 @@ class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter provideMainActivityClass() { + return MainActivity.class; + } + + @Provides + @Singleton + static AppDatabase provideAppDatabase(Application app) { + return databaseBuilder(app, AppDatabase.class, DATABASE_NAME).build(); + } +} 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 0a99070..48d0671 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 @@ -20,11 +20,11 @@ import android.os.Bundle import android.widget.ArrayAdapter import androidx.appcompat.app.AppCompatActivity import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ValidationMode -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.indexToValidationMode +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 @@ -119,7 +119,7 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView { url = inputUrl.trimmedText(), checkInterval = checkInterval, validationMode = validationMode, - validationContent = validationMode.validationContent(), + validationArgs = validationMode.validationContent(), networkTimeout = responseTimeoutInput.textAsInt(defaultValue = defaultTimeout) ) } 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 index aff0c90..45fc9f3 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt @@ -17,13 +17,14 @@ package com.afollestad.nocknock.ui.addsite import androidx.annotation.CheckResult import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.WAITING -import com.afollestad.nocknock.data.ValidationMode -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.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 @@ -63,7 +64,7 @@ interface AddSitePresenter { url: String, checkInterval: Long, validationMode: ValidationMode, - validationContent: String?, + validationArgs: String?, networkTimeout: Int ) @@ -72,8 +73,8 @@ interface AddSitePresenter { /** @author Aidan Follestad (@afollestad) */ class RealAddSitePresenter @Inject constructor( - private val serverModelStore: ServerModelStore, - private val checkStatusManager: CheckStatusManager + private val database: AppDatabase, + private val checkStatusManager: ValidationManager ) : AddSitePresenter { private var view: AddSiteView? = null @@ -118,7 +119,7 @@ class RealAddSitePresenter @Inject constructor( url: String, checkInterval: Long, validationMode: ValidationMode, - validationContent: String?, + validationArgs: String?, networkTimeout: Int ) { val inputErrors = InputErrors() @@ -134,9 +135,9 @@ class RealAddSitePresenter @Inject constructor( if (checkInterval <= 0) { inputErrors.checkInterval = R.string.please_enter_check_interval } - if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) { + if (validationMode == TERM_SEARCH && validationArgs.isNullOrEmpty()) { inputErrors.termSearch = R.string.please_enter_search_term - } else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) { + } else if (validationMode == JAVASCRIPT && validationArgs.isNullOrEmpty()) { inputErrors.javaScript = R.string.please_enter_javaScript } if (networkTimeout <= 0) { @@ -148,14 +149,19 @@ class RealAddSitePresenter @Inject constructor( return } - val newModel = ServerModel( + val newSettings = SiteSettings( + validationIntervalMs = checkInterval, + validationMode = validationMode, + validationArgs = validationArgs, + networkTimeout = networkTimeout, + disabled = false + ) + val newModel = Site( + id = 0, name = name, url = url, - status = WAITING, - checkInterval = checkInterval, - validationMode = validationMode, - validationContent = validationContent, - networkTimeout = networkTimeout + settings = newSettings, + lastResult = null ) with(view!!) { @@ -163,7 +169,7 @@ class RealAddSitePresenter @Inject constructor( launch(coroutineContext) { setLoading() val storedModel = async(IO) { - serverModelStore.put(newModel) + database.putSite(newModel) }.await() checkStatusManager.scheduleCheck( 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 b77de55..3ff7999 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 @@ -28,9 +28,9 @@ import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItems import com.afollestad.nocknock.R import com.afollestad.nocknock.adapter.ServerAdapter -import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.dialogs.AboutDialog -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE +import com.afollestad.nocknock.engine.statuscheck.ValidationJob.Companion.ACTION_STATUS_UPDATE import com.afollestad.nocknock.utilities.ext.ScopeReceiver import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver @@ -39,6 +39,7 @@ 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 @@ -109,18 +110,22 @@ class MainActivity : AppCompatActivity(), MainView { super.onDestroy() } - override fun setModels(models: List) { + 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: ServerModel) { + override fun updateModel(model: Site) { list.post { adapter.update(model) } } - override fun onSiteDeleted(model: ServerModel) { + override fun onSiteDeleted(model: Site) { list.post { adapter.remove(model) emptyText.showOrHide(adapter.itemCount == 0) @@ -133,7 +138,7 @@ class MainActivity : AppCompatActivity(), MainView { ) = rootView.scopeWhileAttached(context, exec) private fun onSiteSelected( - model: ServerModel, + model: Site, longClick: Boolean ) { if (longClick) { 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 e9780bf..acfbfd4 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 @@ -18,7 +18,7 @@ package com.afollestad.nocknock.ui.main import android.content.Intent import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.toHtml import com.afollestad.nocknock.ui.addsite.AddSiteActivity import com.afollestad.nocknock.ui.addsite.KEY_FAB_SIZE @@ -47,16 +47,16 @@ private fun MainActivity.intentToAdd( addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) } -internal fun MainActivity.viewSite(model: ServerModel) { +internal fun MainActivity.viewSite(model: Site) { startActivityForResult(intentToView(model), VIEW_SITE_RQ) } -private fun MainActivity.intentToView(model: ServerModel) = +private fun MainActivity.intentToView(model: Site) = Intent(this, ViewSiteActivity::class.java).apply { putExtra(KEY_VIEW_MODEL, model) } -internal fun MainActivity.maybeRemoveSite(model: ServerModel) { +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()) @@ -67,7 +67,7 @@ internal fun MainActivity.maybeRemoveSite(model: ServerModel) { internal fun MainActivity.processIntent(intent: Intent) { if (intent.hasExtra(KEY_VIEW_NOTIFICATION_MODEL)) { - val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as ServerModel + val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as Site viewSite(model) } } 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 index 9f3fd8d..efd9c64 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt @@ -15,18 +15,25 @@ */ 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.ServerModel -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.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 { @@ -37,18 +44,19 @@ interface MainPresenter { fun resume() - fun refreshSite(site: ServerModel) + fun refreshSite(site: Site) - fun removeSite(site: ServerModel) + fun removeSite(site: Site) fun dropView() } /** @author Aidan Follestad (@afollestad) */ class RealMainPresenter @Inject constructor( - private val serverModelStore: ServerModelStore, + private val app: Application, + private val database: AppDatabase, private val notificationManager: NockNotificationManager, - private val checkStatusManager: CheckStatusManager + private val checkStatusManager: ValidationManager ) : MainPresenter { private var view: MainView? = null @@ -61,40 +69,46 @@ class RealMainPresenter @Inject constructor( override fun onBroadcast(intent: Intent) { if (intent.action == ACTION_STATUS_UPDATE) { - val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return + 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) { - val models = async(IO) { - serverModelStore.get() - }.await() + doMigrationIfNeeded() + + val models = async(IO) { database.allSites() }.await() + setModels(models) + setDoneLoading() } } } } - override fun refreshSite(site: ServerModel) { + override fun refreshSite(site: Site) = checkStatusManager.scheduleCheck( site = site, rightNow = true, cancelPrevious = true ) - } - override fun removeSite(site: ServerModel) { + override fun removeSite(site: Site) { checkStatusManager.cancelCheck(site) notificationManager.cancelStatusNotification(site) + view!!.scopeWhileAttached(Main) { launch(coroutineContext) { - async(IO) { serverModelStore.delete(site) }.await() + async(IO) { database.deleteSite(site) }.await() view?.onSiteDeleted(site) } } @@ -104,6 +118,28 @@ class RealMainPresenter @Inject constructor( 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) { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt index d0dfa71..a8004d2 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt @@ -15,18 +15,22 @@ */ package com.afollestad.nocknock.ui.main -import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.utilities.ext.ScopeReceiver import kotlin.coroutines.CoroutineContext /** @author Aidan Follestad (@afollestad) */ interface MainView { - fun setModels(models: List) + fun setLoading() - fun updateModel(model: ServerModel) + fun setDoneLoading() - fun onSiteDeleted(model: ServerModel) + fun setModels(models: List) + + fun updateModel(model: Site) + + fun onSiteDeleted(model: Site) fun scopeWhileAttached( context: CoroutineContext, 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 8dc66a8..b383aeb 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 @@ -24,15 +24,15 @@ import android.os.Bundle import android.widget.ArrayAdapter import androidx.appcompat.app.AppCompatActivity import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.LAST_CHECK_NONE -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ValidationMode -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.indexToValidationMode -import com.afollestad.nocknock.data.textRes -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE +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.utilities.ext.injector @@ -63,6 +63,7 @@ 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 @@ -132,7 +133,7 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView { url = inputUrl.trimmedText(), checkInterval = checkInterval, validationMode = validationMode, - validationContent = validationMode.validationContent(), + validationArgs = validationMode.validationContent(), networkTimeout = responseTimeoutInput.textAsInt(defaultValue = defaultTimeout) ) } @@ -170,44 +171,48 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView { override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res) - override fun displayModel(model: ServerModel) = with(model) { - iconStatus.setStatus(this.status) + 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.lastCheck == LAST_CHECK_NONE) { + if (this.lastResult == null) { textLastCheckResult.setText(R.string.none) } else { - val statusText = this.status.textRes() + val statusText = this.lastResult!!.status.textRes() textLastCheckResult.text = if (statusText == 0) { - this.reason + this.lastResult!!.reason } else { getString(statusText) } } - if (this.disabled) { + if (siteSettings.disabled) { textNextCheck.setText(R.string.auto_checks_disabled) } else { - textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate() + val lastCheck = this.lastResult?.timestampMs ?: currentTimeMillis() + textNextCheck.text = (lastCheck + siteSettings.validationIntervalMs).formatDate() } - checkIntervalLayout.set(this.checkInterval) + checkIntervalLayout.set(siteSettings.validationIntervalMs) - responseValidationMode.setSelection(validationMode.value - 1) - when (this.validationMode) { - TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "") - JAVASCRIPT -> scriptInputLayout.setCode(this.validationContent) + 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(model.networkTimeout.toString()) + responseTimeoutInput.setText(siteSettings.networkTimeout.toString()) - disableChecksButton.showOrHide(!this.disabled) + disableChecksButton.showOrHide(!siteSettings.disabled) doneBtn.setText( - if (this.disabled) R.string.renable_and_save_changes + if (siteSettings.disabled) R.string.renable_and_save_changes else R.string.save_changes ) 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 063fbcc..6888d76 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,8 +18,8 @@ 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.ServerModel -import com.afollestad.nocknock.data.isPending +import com.afollestad.nocknock.data.model.Site +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 @@ -46,11 +46,11 @@ internal fun ViewSiteActivity.maybeDisableChecks() { } } -internal fun ViewSiteActivity.invalidateMenuForStatus(model: ServerModel) { +internal fun ViewSiteActivity.invalidateMenuForStatus(model: Site) { val refreshIcon = toolbar.menu.findItem(R.id.refresh) .actionView as ImageView - if (model.status.isPending()) { + if (model.lastResult?.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 index 8c66570..4f936d2 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt @@ -18,15 +18,17 @@ package com.afollestad.nocknock.ui.viewsite import android.content.Intent import androidx.annotation.CheckResult import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.WAITING -import com.afollestad.nocknock.data.ValidationMode -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.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 @@ -77,7 +79,7 @@ interface ViewSitePresenter { url: String, checkInterval: Long, validationMode: ValidationMode, - validationContent: String?, + validationArgs: String?, networkTimeout: Int ) @@ -87,26 +89,26 @@ interface ViewSitePresenter { fun removeSite() - fun currentModel(): ServerModel + fun currentModel(): Site fun dropView() } /** @author Aidan Follestad (@afollestad) */ class RealViewSitePresenter @Inject constructor( - private val serverModelStore: ServerModelStore, - private val checkStatusManager: CheckStatusManager, + private val database: AppDatabase, + private val checkStatusManager: ValidationManager, private val notificationManager: NockNotificationManager ) : ViewSitePresenter { private var view: ViewSiteView? = null - private var currentModel: ServerModel? = null + private var currentModel: Site? = null override fun takeView( view: ViewSiteView, intent: Intent ) { - this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel + this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as Site this.view = view.apply { displayModel(currentModel!!) } @@ -114,7 +116,8 @@ class RealViewSitePresenter @Inject constructor( override fun onBroadcast(intent: Intent) { if (intent.action == ACTION_STATUS_UPDATE) { - val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return + val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site + ?: return this.currentModel = model view?.displayModel(model) } @@ -122,7 +125,7 @@ class RealViewSitePresenter @Inject constructor( override fun onNewIntent(intent: Intent?) { if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) { - currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel + currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as Site view?.displayModel(currentModel!!) } } @@ -163,7 +166,7 @@ class RealViewSitePresenter @Inject constructor( url: String, checkInterval: Long, validationMode: ValidationMode, - validationContent: String?, + validationArgs: String?, networkTimeout: Int ) { val inputErrors = InputErrors() @@ -179,9 +182,9 @@ class RealViewSitePresenter @Inject constructor( if (checkInterval <= 0) { inputErrors.checkInterval = R.string.please_enter_check_interval } - if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) { + if (validationMode == TERM_SEARCH && validationArgs.isNullOrEmpty()) { inputErrors.termSearch = R.string.please_enter_search_term - } else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) { + } else if (validationMode == JAVASCRIPT && validationArgs.isNullOrEmpty()) { inputErrors.javaScript = R.string.please_enter_javaScript } if (networkTimeout <= 0) { @@ -193,27 +196,34 @@ class RealViewSitePresenter @Inject constructor( return } - val newModel = currentModel!!.copy( - name = name, - url = url, - status = WAITING, - checkInterval = checkInterval, + val updatedSettings = currentModel!!.settings!!.copy( + validationIntervalMs = checkInterval, validationMode = validationMode, - validationContent = validationContent, + 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) { serverModelStore.update(newModel) }.await() + async(IO) { + database.updateSite(updatedModel) + }.await() + checkStatusManager.scheduleCheck( - site = newModel, + site = updatedModel, rightNow = true, cancelPrevious = true ) + setDoneLoading() view?.finish() } @@ -222,7 +232,7 @@ class RealViewSitePresenter @Inject constructor( } override fun checkNow() = with(view!!) { - val checkModel = currentModel!!.copy( + val checkModel = currentModel!!.withStatus( status = WAITING ) view?.displayModel(checkModel) @@ -242,8 +252,16 @@ class RealViewSitePresenter @Inject constructor( scopeWhileAttached(Main) { launch(coroutineContext) { setLoading() - currentModel = currentModel!!.copy(disabled = true) - async(IO) { serverModelStore.update(currentModel!!) }.await() + currentModel = currentModel!!.copy( + settings = currentModel!!.settings!!.copy( + disabled = true + ) + ) + + async(IO) { + database.updateSite(currentModel!!) + }.await() + setDoneLoading() view?.displayModel(currentModel!!) } @@ -260,7 +278,9 @@ class RealViewSitePresenter @Inject constructor( scopeWhileAttached(Main) { launch(coroutineContext) { setLoading() - async(IO) { serverModelStore.delete(site) }.await() + async(IO) { + database.deleteSite(site) + }.await() setDoneLoading() view?.finish() } @@ -275,7 +295,7 @@ class RealViewSitePresenter @Inject constructor( currentModel = null } - @TestOnly fun setModel(model: ServerModel) { + @TestOnly fun setModel(model: Site) { this.currentModel = model } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt index 4c79368..7ce7180 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt @@ -16,7 +16,7 @@ package com.afollestad.nocknock.ui.viewsite import androidx.annotation.StringRes -import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.utilities.ext.ScopeReceiver import kotlin.coroutines.CoroutineContext @@ -27,7 +27,7 @@ interface ViewSiteView { fun setDoneLoading() - fun displayModel(model: ServerModel) + fun displayModel(model: Site) fun showOrHideUrlSchemeWarning(show: Boolean) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0ca585a..3d271c7 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -48,4 +48,12 @@ app:rippleColor="#40ffffff" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0273e3..ce75a62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,33 +22,32 @@ Please enter a name! Please enter a URL. Please enter a valid URL. - Please input a check interval. + Please input a validation interval. Please input a search term. Please input a validation script. Please enter a network timeout greater than 0. Options - Already checking sites! Remove Site %1$s from your sites?]]> Remove Save Changes View Site - Last Check Result - Next Check - Next Check: %1$s + Last Validation Result + Next Validation + Next Validation: %1$s Now None (turned off) None - Disable Automatic Checks + Disable Automatic Validation %1$s? The site will not be validated in the background - until you re-enable checks for it. You can still manually perform checks by tapping the + Disable automatic validation for %1$s? The site will not be checked in the background + until you re-enable validation for it. You can still manually perform validation by tapping the Refresh icon at the top of this page. ]]> Disable - Enable Checks & Save Changes + Enable Auto Validation & Save Changes Network Response Timeout 10000 @@ -56,22 +55,22 @@ Refresh Status - Warning: this app checks for server availability with HTTP requests. It\'s recommended that you + Warning: this app validates sites availability with HTTP requests. It\'s recommended that you use an HTTP URL. Response Validation Mode Search term… - The HTTP status code is checked. If it\'s a successful status code, the site passes the check. + The HTTP status code is checked. If it\'s a successful status code, the site passes validation. The status code check is done first. If it\'s successful, the response body is checked. - If it contains your search term, the site passes the check. + If it contains your search term, the site passes validation. The status code check is done first. If it\'s successful, the response body is passed to the - JavaScript function above. If the function returns true, the site passes the check. Throw an + JavaScript function above. If the function returns true, the site passes validation. Throw an exception to pass custom error messages to Nock Nock. diff --git a/app/src/test/java/com/afollestad/nocknock/AddSitePresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/AddSitePresenterTest.kt index 20d925c..3c3dd01 100644 --- a/app/src/test/java/com/afollestad/nocknock/AddSitePresenterTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/AddSitePresenterTest.kt @@ -15,12 +15,12 @@ */ package com.afollestad.nocknock -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.SiteSettings +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.engine.statuscheck.ValidationManager import com.afollestad.nocknock.ui.addsite.AddSiteView import com.afollestad.nocknock.ui.addsite.InputErrors import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter @@ -42,16 +42,12 @@ import org.junit.Test class AddSitePresenterTest { - private val serverModelStore = mock { - on { runBlocking { put(any()) } } doAnswer { inv -> - inv.getArgument(0) - } - } - private val checkStatusManager = mock() + private val database = mockDatabase() + private val checkStatusManager = mock() private val view = mock() private val presenter = RealAddSitePresenter( - serverModelStore, + database, checkStatusManager ) @@ -260,10 +256,21 @@ class AddSitePresenterTest { 60000 ) - val modelCaptor = argumentCaptor() + val siteCaptor = argumentCaptor() + val settingsCaptor = argumentCaptor() + verify(view).setLoading() - verify(serverModelStore).put(modelCaptor.capture()) - val model = modelCaptor.firstValue + 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, @@ -271,6 +278,7 @@ class AddSitePresenterTest { cancelPrevious = true, fromFinishingJob = false ) + verify(view).setDoneLoading() verify(view).onSiteAdded() } diff --git a/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt index 56bc2ff..04135d7 100644 --- a/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/MainPresenterTest.kt @@ -15,13 +15,14 @@ */ package com.afollestad.nocknock +import android.app.Application +import android.content.Context.MODE_PRIVATE import android.content.Intent -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import 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 @@ -42,13 +43,22 @@ import org.junit.Test class MainPresenterTest { - private val serverModelStore = mock() + 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 checkStatusManager = mock() private val view = mock() private val presenter = RealMainPresenter( - serverModelStore, + app, + database, notificationManager, checkStatusManager ) @@ -72,55 +82,45 @@ class MainPresenterTest { val badIntent = fakeIntent("Hello World") presenter.onBroadcast(badIntent) - val model = fakeModel() val goodIntent = fakeIntent(ACTION_STATUS_UPDATE) whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL)) - .doReturn(model) + .doReturn(MOCK_MODEL_2) presenter.onBroadcast(goodIntent) - verify(view, times(1)).updateModel(model) + verify(view, times(1)).updateModel(MOCK_MODEL_2) } @Test fun resume() = runBlocking { - val model = fakeModel() - whenever(serverModelStore.get()).doReturn(listOf(model)) presenter.resume() verify(notificationManager).cancelStatusNotifications() - val modelsCaptor = argumentCaptor>() + val modelsCaptor = argumentCaptor>() verify(view, times(2)).setModels(modelsCaptor.capture()) assertThat(modelsCaptor.firstValue).isEmpty() - assertThat(modelsCaptor.lastValue.single()).isEqualTo(model) + assertThat(modelsCaptor.lastValue).isEqualTo(ALL_MOCK_MODELS) } @Test fun refreshSite() { - val model = fakeModel() - presenter.refreshSite(model) + presenter.refreshSite(MOCK_MODEL_3) verify(checkStatusManager).scheduleCheck( - site = model, + site = MOCK_MODEL_3, rightNow = true, cancelPrevious = true ) } @Test fun removeSite() = runBlocking { - val model = fakeModel() - presenter.removeSite(model) + presenter.removeSite(MOCK_MODEL_1) - verify(checkStatusManager).cancelCheck(model) - verify(notificationManager).cancelStatusNotification(model) - verify(serverModelStore).delete(model) - verify(view).onSiteDeleted(model) + 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 fakeModel() = ServerModel( - name = "Test", - url = "https://test.com", - validationMode = STATUS_CODE - ) - 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/TestUtil.kt new file mode 100644 index 0000000..fe5d524 --- /dev/null +++ b/app/src/test/java/com/afollestad/nocknock/TestUtil.kt @@ -0,0 +1,125 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock + +import com.afollestad.nocknock.data.AppDatabase +import com.afollestad.nocknock.data.SiteDao +import com.afollestad.nocknock.data.SiteSettingsDao +import com.afollestad.nocknock.data.ValidationResultsDao +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.SiteSettings +import com.afollestad.nocknock.data.model.Status +import com.afollestad.nocknock.data.model.Status.OK +import com.afollestad.nocknock.data.model.ValidationMode +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.model.ValidationResult +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.isA +import com.nhaarman.mockitokotlin2.mock +import java.lang.System.currentTimeMillis + +fun fakeSettingsModel( + id: Long, + validationMode: ValidationMode = STATUS_CODE +) = SiteSettings( + siteId = id, + validationIntervalMs = 600000, + validationMode = validationMode, + validationArgs = null, + disabled = false, + networkTimeout = 10000 +) + +fun fakeResultModel( + id: Long, + status: Status = OK, + reason: String? = null +) = ValidationResult( + siteId = id, + status = status, + reason = reason, + timestampMs = currentTimeMillis() +) + +fun fakeModel(id: Long) = Site( + id = id, + name = "Test", + url = "https://test.com", + settings = fakeSettingsModel(id), + lastResult = fakeResultModel(id) +) + +val MOCK_MODEL_1 = fakeModel(1) +val MOCK_MODEL_2 = fakeModel(2) +val MOCK_MODEL_3 = fakeModel(3) +val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3) + +fun mockDatabase(): AppDatabase { + val siteDao = mock { + on { insert(isA()) } doReturn 1 + on { one(isA()) } doAnswer { inv -> + val id = inv.getArgument(0) + return@doAnswer when (id) { + 1L -> listOf(MOCK_MODEL_1) + 2L -> listOf(MOCK_MODEL_2) + 3L -> listOf(MOCK_MODEL_3) + else -> listOf() + } + } + on { all() } doReturn ALL_MOCK_MODELS + on { update(isA()) } doAnswer { inv -> + return@doAnswer inv.arguments.size + } + on { delete(isA()) } doAnswer { inv -> + return@doAnswer inv.arguments.size + } + } + val settingsDao = mock { + on { insert(isA()) } doReturn 1L + on { forSite(isA()) } doAnswer { inv -> + val id = inv.getArgument(0) + return@doAnswer when (id) { + 1L -> listOf(MOCK_MODEL_1.settings!!) + 2L -> listOf(MOCK_MODEL_2.settings!!) + 3L -> listOf(MOCK_MODEL_3.settings!!) + else -> listOf() + } + } + on { update(isA()) } doReturn 1 + on { delete(isA()) } doReturn 1 + } + val resultsDao = mock { + on { insert(isA()) } doReturn 1L + on { forSite(isA()) } doAnswer { inv -> + val id = inv.getArgument(0) + return@doAnswer when (id) { + 1L -> listOf(MOCK_MODEL_1.lastResult!!) + 2L -> listOf(MOCK_MODEL_2.lastResult!!) + 3L -> listOf(MOCK_MODEL_3.lastResult!!) + else -> listOf() + } + } + on { update(isA()) } doReturn 1 + on { delete(isA()) } doReturn 1 + } + + return mock { + on { siteDao() } doReturn siteDao + on { siteSettingsDao() } doReturn settingsDao + on { validationResultsDao() } doReturn resultsDao + } +} diff --git a/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt b/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt index 2a7e1dd..37a78b0 100644 --- a/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/ViewSitePresenterTest.kt @@ -16,16 +16,17 @@ package com.afollestad.nocknock import android.content.Intent -import com.afollestad.nocknock.data.LAST_CHECK_NONE -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.WAITING -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager +import com.afollestad.nocknock.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 @@ -47,20 +48,17 @@ 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 serverModelStore = mock { - on { runBlocking { put(any()) } } doAnswer { inv -> - inv.getArgument(0) - } - } - private val checkStatusManager = mock() + private val database = mockDatabase() + private val checkStatusManager = mock() private val notificationManager = mock() private val view = mock() private val presenter = RealViewSitePresenter( - serverModelStore, + database, checkStatusManager, notificationManager ) @@ -73,13 +71,12 @@ class ViewSitePresenterTest { }.whenever(view) .scopeWhileAttached(any(), any()) - val model = fakeModel() val intent = fakeIntent("") whenever(intent.getSerializableExtra(KEY_VIEW_MODEL)) - .doReturn(model) + .doReturn(MOCK_MODEL_1) presenter.takeView(view, intent) - assertThat(presenter.currentModel()).isEqualTo(model) - verify(view, times(1)).displayModel(model) + assertThat(presenter.currentModel()).isEqualTo(MOCK_MODEL_1) + verify(view, times(1)).displayModel(MOCK_MODEL_1) } @After fun destroy() { @@ -90,27 +87,25 @@ class ViewSitePresenterTest { val badIntent = fakeIntent("Hello World") presenter.onBroadcast(badIntent) - val model = fakeModel().copy(lastCheck = 0) val goodIntent = fakeIntent(ACTION_STATUS_UPDATE) whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL)) - .doReturn(model) + .doReturn(MOCK_MODEL_2) presenter.onBroadcast(goodIntent) - assertThat(presenter.currentModel()).isEqualTo(model) - verify(view, times(1)).displayModel(model) + 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 model = fakeModel().copy(lastCheck = 0) val goodIntent = fakeIntent(ACTION_STATUS_UPDATE) whenever(goodIntent.getSerializableExtra(KEY_VIEW_MODEL)) - .doReturn(model) + .doReturn(MOCK_MODEL_3) presenter.onBroadcast(goodIntent) - verify(view, times(1)).displayModel(model) + verify(view, times(1)).displayModel(MOCK_MODEL_3) } @Test fun onUrlInputFocusChange_focused() { @@ -298,10 +293,19 @@ class ViewSitePresenterTest { val url = "https://hello.com" val checkInterval = 60000L val validationMode = TERM_SEARCH - val validationContent = "Hello World" + val validationArgs = "Hello World" - val disabledModel = presenter.currentModel() - .copy(disabled = true) + 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( @@ -309,21 +313,38 @@ class ViewSitePresenterTest { url, checkInterval, validationMode, - validationContent, + validationArgs, 60000 ) - val modelCaptor = argumentCaptor() - verify(view).setLoading() - verify(serverModelStore).update(modelCaptor.capture()) + val siteCaptor = argumentCaptor() + val settingsCaptor = argumentCaptor() + val resultCaptor = argumentCaptor() - val model = modelCaptor.firstValue - assertThat(model.name).isEqualTo(name) - assertThat(model.url).isEqualTo(url) - assertThat(model.checkInterval).isEqualTo(checkInterval) - assertThat(model.validationMode).isEqualTo(validationMode) - assertThat(model.validationContent).isEqualTo(validationContent) - assertThat(model.disabled).isFalse() + 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( @@ -338,9 +359,7 @@ class ViewSitePresenterTest { @Test fun checkNow() { val newModel = presenter.currentModel() - .copy( - status = WAITING - ) + .withStatus(status = WAITING) presenter.checkNow() verify(view, never()).setLoading() @@ -360,11 +379,18 @@ class ViewSitePresenterTest { verify(notificationManager).cancelStatusNotification(model) verify(view).setLoading() - val modelCaptor = argumentCaptor() - verify(serverModelStore).update(modelCaptor.capture()) + 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 - assertThat(newModel.disabled).isTrue() - assertThat(newModel.lastCheck).isEqualTo(LAST_CHECK_NONE) + val newSettings = settingsCaptor.firstValue + val result = resultCaptor.firstValue + assertThat(newSettings.disabled).isTrue() verify(view).setDoneLoading() verify(view, times(1)).displayModel(newModel) @@ -377,18 +403,15 @@ class ViewSitePresenterTest { verify(checkStatusManager).cancelCheck(model) verify(notificationManager).cancelStatusNotification(model) verify(view).setLoading() - verify(serverModelStore).delete(model) + + 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 fakeModel() = ServerModel( - id = 1, - name = "Test", - url = "https://test.com", - validationMode = STATUS_CODE - ) - private fun fakeIntent(action: String): Intent { return mock { on { getAction() } doReturn action diff --git a/data/build.gradle b/data/build.gradle index 4ff907d..6a79c06 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -1,6 +1,7 @@ apply from: '../dependencies.gradle' apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' android { compileSdkVersion versions.compileSdk @@ -10,6 +11,8 @@ android { targetSdkVersion versions.compileSdk versionCode versions.publishVersionCode versionName versions.publishVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } } @@ -17,6 +20,17 @@ dependencies { implementation project(':utilities') implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin + + implementation 'com.google.dagger:dagger:' + versions.dagger + annotationProcessor 'com.google.dagger:dagger-compiler:' + versions.dagger + + api 'androidx.room:room-runtime:' + versions.room + kapt 'androidx.room:room-compiler:' + versions.room + + androidTestImplementation 'androidx.test:runner:' + versions.androidxTestRunner + androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner + androidTestImplementation 'androidx.test:core:' + versions.androidxTest + androidTestImplementation 'com.google.truth:truth:' + versions.truth } apply from: '../spotless.gradle' \ No newline at end of file diff --git a/data/src/androidTest/AndroidManifest.xml b/data/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..17a2157 --- /dev/null +++ b/data/src/androidTest/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt new file mode 100644 index 0000000..27e222c --- /dev/null +++ b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt @@ -0,0 +1,350 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyPrimaryConstructor") + +package com.afollestad.nocknock.data + +import android.content.Context +import androidx.room.Room.inMemoryDatabaseBuilder +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.runner.AndroidJUnit4 +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.SiteSettings +import com.afollestad.nocknock.data.model.Status.ERROR +import com.afollestad.nocknock.data.model.Status.OK +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.data.model.ValidationResult +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import java.lang.System.currentTimeMillis + +/** @author Aidan Follestad (@afollestad) */ +@RunWith(AndroidJUnit4::class) +class AppDatabaseTest() { + + private lateinit var db: AppDatabase + private lateinit var sitesDao: SiteDao + private lateinit var settingsDao: SiteSettingsDao + private lateinit var resultsDao: ValidationResultsDao + + @Before fun setup() { + val context = getApplicationContext() + db = inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + sitesDao = db.siteDao() + settingsDao = db.siteSettingsDao() + resultsDao = db.validationResultsDao() + } + + @After + @Throws(IOException::class) + fun destroy() { + db.close() + } + + // SiteDao + + @Test fun site_insert_and_get_all() { + val model1 = Site( + name = "Test 1", + url = "https://test1.com", + settings = null, + lastResult = null + ) + val newId1 = sitesDao.insert(model1) + assertThat(newId1).isGreaterThan(0) + + val model2 = Site( + name = "Test 2", + url = "https://test2.com", + settings = null, + lastResult = null + ) + val newId2 = sitesDao.insert(model2) + assertThat(newId2).isGreaterThan(newId1) + + val models = sitesDao.all() + assertThat(models.size).isEqualTo(2) + assertThat(models[0]).isEqualTo(model1.copy(id = newId1)) + assertThat(models[1]).isEqualTo(model2.copy(id = newId2)) + } + + @Test fun site_insert_and_get_one() { + val model = Site( + name = "Test", + url = "https://test.com", + settings = null, + lastResult = null + ) + val newId = sitesDao.insert(model) + assertThat(newId).isGreaterThan(0) + + val models = sitesDao.all() + assertThat(models.single()).isEqualTo(model.copy(id = newId)) + } + + @Test fun site_insert_and_update() { + val initialModel = Site( + name = "Test 1", + url = "https://test1.com", + settings = null, + lastResult = null + ) + val newId = sitesDao.insert(initialModel) + assertThat(newId).isGreaterThan(0) + + val insertedModel = sitesDao.all() + .single() + val updatedModel = insertedModel.copy( + name = "Test 2", + url = "https://hi.com" + ) + assertThat(sitesDao.update(updatedModel)).isEqualTo(1) + + val finalModel = sitesDao.all() + .single() + assertThat(finalModel).isNotEqualTo(initialModel.copy(id = newId)) + } + + @Test fun site_insert_and_delete() { + val model1 = Site( + name = "Test 1", + url = "https://test1.com", + settings = null, + lastResult = null + ) + val newId1 = sitesDao.insert(model1) + assertThat(newId1).isGreaterThan(0) + + val model2 = Site( + name = "Test 2", + url = "https://test2.com", + settings = null, + lastResult = null + ) + val newId2 = sitesDao.insert(model2) + assertThat(newId2).isGreaterThan(newId1) + + val models1 = sitesDao.all() + sitesDao.delete(models1[0]) + + val models2 = sitesDao.all() + assertThat(models2.single()).isEqualTo(models1[1]) + } + + // SiteSettingsDao + + @Test fun settings_insert_and_forSite() { + val model = SiteSettings( + siteId = 1, + validationIntervalMs = 60000, + validationMode = STATUS_CODE, + validationArgs = null, + disabled = false, + networkTimeout = 10000 + ) + val newId = settingsDao.insert(model) + assertThat(newId).isEqualTo(1) + + val finalModel = settingsDao.forSite(newId) + .single() + assertThat(finalModel).isEqualTo(model.copy(siteId = newId)) + } + + @Test fun settings_update() { + settingsDao.insert( + SiteSettings( + siteId = 1, + validationIntervalMs = 60000, + validationMode = STATUS_CODE, + validationArgs = null, + disabled = false, + networkTimeout = 10000 + ) + ) + + val insertedModel = settingsDao.forSite(1) + .single() + val updatedModel = insertedModel.copy( + validationIntervalMs = 10000, + validationMode = TERM_SEARCH, + validationArgs = "test", + disabled = false, + networkTimeout = 1000 + ) + assertThat(settingsDao.update(updatedModel)).isEqualTo(1) + + val finalModel = settingsDao.forSite(1) + .single() + assertThat(finalModel).isEqualTo(updatedModel) + } + + @Test fun settings_delete() { + settingsDao.insert( + SiteSettings( + siteId = 1, + validationIntervalMs = 60000, + validationMode = STATUS_CODE, + validationArgs = null, + disabled = false, + networkTimeout = 10000 + ) + ) + + val insertedModel = settingsDao.forSite(1) + .single() + settingsDao.delete(insertedModel) + assertThat(settingsDao.forSite(1)).isEmpty() + } + + // ValidationResultsDao + + @Test fun validation_insert_and_forSite() { + val model = ValidationResult( + siteId = 1, + timestampMs = currentTimeMillis(), + status = ERROR, + reason = "Oh no" + ) + val newId = resultsDao.insert(model) + assertThat(newId).isEqualTo(1) + + val finalModel = resultsDao.forSite(newId) + .single() + assertThat(finalModel).isEqualTo(model.copy(siteId = newId)) + } + + @Test fun validation_update() { + resultsDao.insert( + ValidationResult( + siteId = 1, + timestampMs = currentTimeMillis(), + status = ERROR, + reason = "Oh no" + ) + ) + + val insertedModel = resultsDao.forSite(1) + .single() + val updatedModel = insertedModel.copy( + timestampMs = currentTimeMillis() + 1000, + status = OK, + reason = null + ) + assertThat(resultsDao.update(updatedModel)).isEqualTo(1) + + val finalModel = resultsDao.forSite(1) + .single() + assertThat(finalModel).isEqualTo(updatedModel) + } + + @Test fun validation_delete() { + resultsDao.insert( + ValidationResult( + siteId = 1, + timestampMs = currentTimeMillis(), + status = ERROR, + reason = "Oh no" + ) + ) + + val insertedModel = resultsDao.forSite(1) + .single() + resultsDao.delete(insertedModel) + assertThat(resultsDao.forSite(1)).isEmpty() + } + + // Extension Methods + + @Test fun extension_put_and_allSites() { + db.putSite(MOCK_MODEL_1) + db.putSite(MOCK_MODEL_2) + db.putSite(MOCK_MODEL_3) + + val allSites = db.allSites() + assertThat(allSites.size).isEqualTo(3) + assertThat(allSites[0]).isEqualTo(MOCK_MODEL_1) + assertThat(allSites[1]).isEqualTo(MOCK_MODEL_2) + assertThat(allSites[2]).isEqualTo(MOCK_MODEL_3) + } + + @Test fun extension_put_getSite() { + db.putSite(MOCK_MODEL_1) + db.putSite(MOCK_MODEL_2) + db.putSite(MOCK_MODEL_3) + val allSites = db.allSites() + + val site = db.getSite(2) + assertThat(site).isEqualTo(allSites[1]) + } + + @Test fun extension_put_updateSite() { + db.putSite(MOCK_MODEL_1) + db.putSite(MOCK_MODEL_2) + db.putSite(MOCK_MODEL_3) + val modelToUpdate = db.allSites()[1] + + val updatedSettings = modelToUpdate.settings!!.copy( + validationIntervalMs = 1, + validationMode = JAVASCRIPT, + validationArgs = "throw 'Hello World'", + disabled = false, + networkTimeout = 50 + ) + val updatedValidationResult = modelToUpdate.lastResult!!.copy( + timestampMs = currentTimeMillis() + 10, + status = ERROR, + reason = "Oh no" + ) + val updatedModel = modelToUpdate.copy( + name = "Oijrfouhef", + url = "https://iojfdfsdk.io", + settings = updatedSettings, + lastResult = updatedValidationResult + ) + + db.updateSite(updatedModel) + + val finalSite = db.getSite(modelToUpdate.id)!! + assertThat(finalSite.settings).isEqualTo(updatedSettings) + assertThat(finalSite.lastResult).isEqualTo(updatedValidationResult) + assertThat(finalSite).isEqualTo(updatedModel) + } + + @Test fun extension_put_and_deleteSite() { + db.putSite(MOCK_MODEL_1) + db.putSite(MOCK_MODEL_2) + db.putSite(MOCK_MODEL_3) + val allSites = db.allSites() + + db.deleteSite(MOCK_MODEL_2) + + val remainingSettings = settingsDao.all() + assertThat(remainingSettings.size).isEqualTo(2) + assertThat(remainingSettings[0]).isEqualTo(allSites[0].settings!!) + assertThat(remainingSettings[1]).isEqualTo(allSites[2].settings!!) + + val remainingResults = resultsDao.all() + assertThat(remainingResults.size).isEqualTo(2) + assertThat(remainingResults[0]).isEqualTo(allSites[0].lastResult!!) + assertThat(remainingResults[1]).isEqualTo(allSites[2].lastResult!!) + } +} diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt new file mode 100644 index 0000000..7d3ed01 --- /dev/null +++ b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt @@ -0,0 +1,60 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.data + +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.SiteSettings +import com.afollestad.nocknock.data.model.Status +import com.afollestad.nocknock.data.model.Status.OK +import com.afollestad.nocknock.data.model.ValidationMode +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.model.ValidationResult +import java.lang.System.currentTimeMillis + +fun fakeSettingsModel( + id: Long, + validationMode: ValidationMode = STATUS_CODE +) = SiteSettings( + siteId = id, + validationIntervalMs = 600000, + validationMode = validationMode, + validationArgs = null, + disabled = false, + networkTimeout = 10000 +) + +fun fakeResultModel( + id: Long, + status: Status = OK, + reason: String? = null +) = ValidationResult( + siteId = id, + status = status, + reason = reason, + timestampMs = currentTimeMillis() +) + +fun fakeModel(id: Long) = Site( + id = id, + name = "Test", + url = "https://test.com", + settings = fakeSettingsModel(id), + lastResult = fakeResultModel(id) +) + +val MOCK_MODEL_1 = fakeModel(1) +val MOCK_MODEL_2 = fakeModel(2) +val MOCK_MODEL_3 = fakeModel(3) diff --git a/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt new file mode 100644 index 0000000..8e49e08 --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt @@ -0,0 +1,147 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.data + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.afollestad.nocknock.data.model.Converters +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.SiteSettings +import com.afollestad.nocknock.data.model.ValidationResult + +/** @author Aidan Follestad (@afollestad) */ +@Database( + entities = [ + ValidationResult::class, + SiteSettings::class, + Site::class + ], + version = 1, + exportSchema = false +) +@TypeConverters(Converters::class) +abstract class AppDatabase : RoomDatabase() { + + abstract fun siteDao(): SiteDao + + abstract fun siteSettingsDao(): SiteSettingsDao + + abstract fun validationResultsDao(): ValidationResultsDao +} + +/** + * Gets all sites and maps their settings and last validation results. + * + * @author Aidan Follestad (@afollestad) + */ +fun AppDatabase.allSites(): List { + return siteDao().all() + .map { + val settings = siteSettingsDao().forSite(it.id) + .single() + val lastResult = validationResultsDao().forSite(it.id) + .singleOrNull() + return@map it.copy( + settings = settings, + lastResult = lastResult + ) + } +} + +/** + * Gets a single site and maps its settings and last validation result. + * + * @author Aidan Follestad (@afollestad) + */ +fun AppDatabase.getSite(id: Long): Site? { + val result = siteDao().one(id) + .singleOrNull() ?: return null + val settings = siteSettingsDao().forSite(id) + .single() + val lastResult = validationResultsDao().forSite(id) + .singleOrNull() + return result.copy( + settings = settings, + lastResult = lastResult + ) +} + +/** + * Inserts a site along with its settings and last result into the database. + * + * @author Aidan Follestad (@afollestad) + */ +fun AppDatabase.putSite(site: Site): Site { + requireNotNull(site.settings) { "Settings must be populated." } + val newId = siteDao().insert(site) + val settingsWithSiteId = + site.settings!!.copy( + siteId = newId + ) + siteSettingsDao().insert(settingsWithSiteId) + site.lastResult?.let { validationResultsDao().insert(it) } + return site.copy( + id = newId, + settings = settingsWithSiteId + ) +} + +/** + * Updates a site, along with its settings and last result, in the database. + * + * @author Aidan Follestad (@afollestad) + */ +fun AppDatabase.updateSite(site: Site) { + siteDao().update(site) + if (site.settings != null) { + val existing = siteSettingsDao().forSite(site.id) + .singleOrNull() + if (existing != null) { + siteSettingsDao().update(site.settings!!) + } else { + siteSettingsDao().insert( + site.settings!!.copy( + siteId = site.id + ) + ) + } + } + if (site.lastResult != null) { + val existing = validationResultsDao().forSite(site.id) + .singleOrNull() + if (existing != null) { + validationResultsDao().update(site.lastResult!!) + } else { + validationResultsDao().insert(site.lastResult!!) + } + } +} + +/** + * Deletes a site along with its settings and last result from the database. + * + * @author Aidan Follestad (@afollestad) + */ +fun AppDatabase.deleteSite(site: Site) { + if (site.settings != null) { + siteSettingsDao().delete(site.settings!!) + } + if (site.lastResult != null) { + validationResultsDao().delete(site.lastResult!!) + } + siteDao().delete(site) +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/ServerStatus.kt b/data/src/main/java/com/afollestad/nocknock/data/ServerStatus.kt deleted file mode 100644 index 9cc28d0..0000000 --- a/data/src/main/java/com/afollestad/nocknock/data/ServerStatus.kt +++ /dev/null @@ -1,50 +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.data - -import com.afollestad.nocknock.data.ServerStatus.CHECKING -import com.afollestad.nocknock.data.ServerStatus.OK -import com.afollestad.nocknock.data.ServerStatus.WAITING - -/** @author Aidan Follestad (@afollestad) */ -enum class ServerStatus(val value: Int) { - OK(1), - WAITING(2), - CHECKING(3), - ERROR(4); - - companion object { - - fun fromValue(value: Int) = when (value) { - OK.value -> OK - WAITING.value -> WAITING - CHECKING.value -> CHECKING - ERROR.value -> ERROR - else -> throw IllegalArgumentException("Unknown validationMode: $value") - } - } -} - -fun ServerStatus.textRes() = when (this) { - OK -> R.string.everything_checks_out - WAITING -> R.string.waiting - CHECKING -> R.string.checking_status - else -> 0 -} - -fun Int.toServerStatus() = ServerStatus.fromValue(this) - -fun ServerStatus.isPending() = this == WAITING || this == CHECKING diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt b/data/src/main/java/com/afollestad/nocknock/data/SiteDao.kt similarity index 50% rename from engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt rename to data/src/main/java/com/afollestad/nocknock/data/SiteDao.kt index b9fd1e1..135a8f1 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/SiteDao.kt @@ -13,29 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.afollestad.nocknock.engine +package com.afollestad.nocknock.data -import com.afollestad.nocknock.engine.db.RealServerModelStore -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager -import com.afollestad.nocknock.engine.statuscheck.RealCheckStatusManager -import dagger.Binds -import dagger.Module -import javax.inject.Singleton +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy.FAIL +import androidx.room.Query +import androidx.room.Update +import com.afollestad.nocknock.data.model.Site /** @author Aidan Follestad (@afollestad) */ -@Module -abstract class EngineModule { +@Dao +interface SiteDao { - @Binds - @Singleton - abstract fun provideServerModelStore( - serverModelStore: RealServerModelStore - ): ServerModelStore + @Query("SELECT * FROM sites ORDER BY name ASC") + fun all(): List - @Binds - @Singleton - abstract fun provideCheckStatusManager( - checkStatusManager: RealCheckStatusManager - ): CheckStatusManager + @Query("SELECT * FROM sites WHERE id = :id LIMIT 1") + fun one(id: Long): List + + @Insert(onConflict = FAIL) + fun insert(site: Site): Long + + @Update(onConflict = FAIL) + fun update(site: Site): Int + + @Delete + fun delete(site: Site): Int } diff --git a/data/src/main/java/com/afollestad/nocknock/data/SiteSettingsDao.kt b/data/src/main/java/com/afollestad/nocknock/data/SiteSettingsDao.kt new file mode 100644 index 0000000..b11f418 --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/SiteSettingsDao.kt @@ -0,0 +1,44 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy.FAIL +import androidx.room.Query +import androidx.room.Update +import com.afollestad.nocknock.data.model.SiteSettings + +/** @author Aidan Follestad (@afollestad) */ +@Dao +interface SiteSettingsDao { + + @Query("SELECT * FROM site_settings ORDER BY siteId ASC") + fun all(): List + + @Query("SELECT * FROM site_settings WHERE siteId = :siteId LIMIT 1") + fun forSite(siteId: Long): List + + @Insert(onConflict = FAIL) + fun insert(siteSetting: SiteSettings): Long + + @Update(onConflict = FAIL) + fun update(siteSetting: SiteSettings): Int + + @Delete + fun delete(siteSetting: SiteSettings): Int +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/ValidationResultsDao.kt b/data/src/main/java/com/afollestad/nocknock/data/ValidationResultsDao.kt new file mode 100644 index 0000000..d6c81dc --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/ValidationResultsDao.kt @@ -0,0 +1,44 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy.FAIL +import androidx.room.Query +import androidx.room.Update +import com.afollestad.nocknock.data.model.ValidationResult + +/** @author Aidan Follestad (@afollestad) */ +@Dao +interface ValidationResultsDao { + + @Query("SELECT * FROM validation_results ORDER BY siteId ASC") + fun all(): List + + @Query("SELECT * FROM validation_results WHERE siteId = :siteId LIMIT 1") + fun forSite(siteId: Long): List + + @Insert(onConflict = FAIL) + fun insert(siteSetting: ValidationResult): Long + + @Update(onConflict = FAIL) + fun update(siteSetting: ValidationResult): Int + + @Delete + fun delete(siteSetting: ValidationResult): Int +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/legacy/DbMigrator.kt b/data/src/main/java/com/afollestad/nocknock/data/legacy/DbMigrator.kt new file mode 100644 index 0000000..5af752b --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/legacy/DbMigrator.kt @@ -0,0 +1,105 @@ +/** + * 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/ServerModel.kt b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModel.kt similarity index 55% rename from data/src/main/java/com/afollestad/nocknock/data/ServerModel.kt rename to data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModel.kt index 474e514..9882705 100644 --- a/data/src/main/java/com/afollestad/nocknock/data/ServerModel.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModel.kt @@ -13,25 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.afollestad.nocknock.data +@file:Suppress("DEPRECATION") + +package com.afollestad.nocknock.data.legacy import android.content.ContentValues import android.database.Cursor -import com.afollestad.nocknock.data.ServerStatus.OK -import com.afollestad.nocknock.utilities.ext.timeString -import com.afollestad.nocknock.utilities.providers.IdProvider -import java.lang.System.currentTimeMillis -import kotlin.math.max +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: ServerStatus = OK, + val status: Status = OK, val checkInterval: Long = CHECK_INTERVAL_UNSET, val lastCheck: Long = LAST_CHECK_NONE, val reason: String? = null, @@ -39,7 +42,7 @@ data class ServerModel( val validationContent: String? = null, val disabled: Boolean = false, val networkTimeout: Int = 0 -) : IdProvider { +) { companion object { const val TABLE_NAME = "server_models" @@ -59,31 +62,65 @@ data class ServerModel( fun pull(cursor: Cursor): ServerModel { return ServerModel( - id = cursor.getInt(cursor.getColumnIndex(COLUMN_ID)), - name = cursor.getString(cursor.getColumnIndex(COLUMN_NAME)), - url = cursor.getString(cursor.getColumnIndex(COLUMN_URL)), - status = cursor.getInt(cursor.getColumnIndex(COLUMN_STATUS)).toServerStatus(), - checkInterval = cursor.getLong(cursor.getColumnIndex(COLUMN_CHECK_INTERVAL)), - lastCheck = cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_CHECK)), - reason = cursor.getString(cursor.getColumnIndex(COLUMN_REASON)), + 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) + 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)) + validationContent = cursor.getString( + cursor.getColumnIndex( + COLUMN_VALIDATION_CONTENT + ) + ), + disabled = cursor.getInt( + cursor.getColumnIndex( + COLUMN_DISABLED + ) + ) == 1, + networkTimeout = cursor.getInt( + cursor.getColumnIndex( + COLUMN_NETWORK_TIMEOUT + ) + ) ) } } - override fun id() = id - - fun intervalText(): String { - val now = currentTimeMillis() - val nextCheck = max(lastCheck, 0) + checkInterval - return (nextCheck - now).timeString() - } - fun toContentValues() = ContentValues().apply { put(COLUMN_NAME, name) put(COLUMN_URL, url) diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelDbHelper.kt b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelDbHelper.kt similarity index 90% rename from engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelDbHelper.kt rename to data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelDbHelper.kt index 2fc9a4d..f0bdb2f 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelDbHelper.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelDbHelper.kt @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.afollestad.nocknock.engine.db +@file:Suppress("DEPRECATION") + +package com.afollestad.nocknock.data.legacy import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -import com.afollestad.nocknock.data.ServerModel private const val SQL_CREATE_ENTRIES = "CREATE TABLE ${ServerModel.TABLE_NAME} (" + @@ -38,8 +39,10 @@ private const val SQL_CREATE_ENTRIES = private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${ServerModel.TABLE_NAME}" /** @author Aidan Follestad (@afollestad) */ -class ServerModelDbHelper(context: Context) : SQLiteOpenHelper( - context, DATABASE_NAME, null, DATABASE_VERSION +@Deprecated("Use AppDatabase.") +internal class ServerModelDbHelper(context: Context) : SQLiteOpenHelper( + context, DATABASE_NAME, null, + DATABASE_VERSION ) { companion object { const val DATABASE_VERSION = 3 @@ -71,6 +74,5 @@ class ServerModelDbHelper(context: Context) : SQLiteOpenHelper( fun wipe() { this.writableDatabase.execSQL(SQL_DELETE_ENTRIES) - onCreate(this.writableDatabase) } } diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelStore.kt b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelStore.kt similarity index 60% rename from engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelStore.kt rename to data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelStore.kt index 9c7ecce..2d0a0cb 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/db/ServerModelStore.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/legacy/ServerModelStore.kt @@ -13,42 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.afollestad.nocknock.engine.db +@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.ServerModel -import com.afollestad.nocknock.data.ServerModel.Companion.COLUMN_ID -import com.afollestad.nocknock.data.ServerModel.Companion.DEFAULT_SORT_ORDER -import com.afollestad.nocknock.data.ServerModel.Companion.TABLE_NAME -import com.afollestad.nocknock.utilities.ext.diffFrom -import org.jetbrains.annotations.TestOnly -import javax.inject.Inject -import timber.log.Timber.d as log -import timber.log.Timber.w as warn +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) */ -interface ServerModelStore { - - suspend fun get(id: Int? = null): List - - suspend fun put(model: ServerModel): ServerModel - - suspend fun update(model: ServerModel): Int - - suspend fun delete(model: ServerModel): Int - - suspend fun delete(id: Int): Int - - suspend fun deleteAll(): Int -} - -/** @author Aidan Follestad (@afollestad) */ -class RealServerModelStore @Inject constructor(app: Application) : ServerModelStore { +@Deprecated("Deprecated in favor of AppDatabase.") +internal class ServerModelStore(app: Application) { private val dbHelper = ServerModelDbHelper(app) - override suspend fun get(id: Int?): List { + fun get(id: Int? = null): List { if (id == null) { return getAll() } @@ -88,19 +70,16 @@ class RealServerModelStore @Inject constructor(app: Application) : ServerModelSt cursor.use { return readModels(it) } } - override suspend fun put(model: ServerModel): ServerModel { + 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()) - .apply { - log("Inserted new site model: $this") - } } - override suspend fun update(model: ServerModel): Int { + 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() @@ -111,35 +90,27 @@ class RealServerModelStore @Inject constructor(app: Application) : ServerModelSt val valuesDiff = oldValues.diffFrom(newValues) if (valuesDiff.size() == 0) { - warn("Nothing has changed - nothing to update!") return 0 } val selection = "$COLUMN_ID = ?" val selectionArgs = arrayOf("${model.id}") - log("Updated model: $model") return writer.update(TABLE_NAME, valuesDiff, selection, selectionArgs) } - override suspend fun delete(model: ServerModel) = delete(model.id) + fun delete(model: ServerModel) = delete(model.id) - override suspend fun delete(id: Int): Int { + 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") - log("Deleted model: $id") return dbHelper.writableDatabase.delete(TABLE_NAME, selection, selectionArgs) } - override suspend fun deleteAll(): Int { - log("Deleted all models") - return dbHelper.writableDatabase.delete(TABLE_NAME, null, null) - } - - @TestOnly fun db() = dbHelper + fun wipe() = dbHelper.wipe() private fun readModels(cursor: Cursor): List { val results = mutableListOf() @@ -148,4 +119,45 @@ class RealServerModelStore @Inject constructor(app: Application) : ServerModelSt } 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/Converters.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Converters.kt new file mode 100644 index 0000000..b532829 --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/model/Converters.kt @@ -0,0 +1,42 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.data.model + +import androidx.room.TypeConverter + +/** @author Aidan Follestad (@afollestad) */ +class Converters { + + @TypeConverter + fun fromStatus(status: Status): Int { + return status.value + } + + @TypeConverter + fun toStatus(raw: Int): Status { + return Status.fromValue(raw) + } + + @TypeConverter + fun fromValidationMode(mode: ValidationMode): Int { + return mode.value + } + + @TypeConverter + fun toValidationMode(raw: Int): ValidationMode { + return ValidationMode.fromValue(raw) + } +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt new file mode 100644 index 0000000..b0539d7 --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt @@ -0,0 +1,76 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.data.model + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import com.afollestad.nocknock.data.model.Status.WAITING +import com.afollestad.nocknock.utilities.ext.timeString +import com.afollestad.nocknock.utilities.providers.CanNotifyModel +import java.lang.System.currentTimeMillis +import kotlin.math.max + +/** @author Aidan Follestad (@afollestad) */ +@Entity(tableName = "sites") +data class Site( + /** The site's unique ID. */ + @PrimaryKey(autoGenerate = true) var id: Long = 0, + /** The site's user-given name. */ + var name: String, + /** The URl at which validation attempts are made to. */ + var url: String, + /** Settings for the site. */ + @Ignore var settings: SiteSettings?, + /** The last validation attempt result for the site, if any. */ + @Ignore var lastResult: ValidationResult? +) : CanNotifyModel { + + constructor() : this(0, "", "", null, null) + + override fun notiId(): Int = id.toInt() + + override fun notiName(): String = name + + override fun notiTag(): String = url + + fun intervalText(): String { + requireNotNull(settings) { "Settings not queried." } + val lastCheck = lastResult?.timestampMs ?: -1 + val checkInterval = settings!!.validationIntervalMs + val now = System.currentTimeMillis() + val nextCheck = max(lastCheck, 0) + checkInterval + return (nextCheck - now).timeString() + } + + fun withStatus( + status: Status? = null, + reason: String? = null, + timestamp: Long? = null + ): Site { + val newLastResult = lastResult?.copy( + status = status ?: lastResult!!.status, + reason = reason, + timestampMs = timestamp ?: lastResult!!.timestampMs + ) ?: ValidationResult( + siteId = this.id, + timestampMs = timestamp ?: currentTimeMillis(), + status = status ?: WAITING, + reason = reason + ) + return this.copy(lastResult = newLastResult) + } +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt b/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt new file mode 100644 index 0000000..91667a7 --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/model/SiteSettings.kt @@ -0,0 +1,47 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("unused") + +package com.afollestad.nocknock.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import java.io.Serializable + +/** + * Represents the current user configuration for a [Site]. + * + * @author Aidan Follestad (@afollestad) + */ +@Entity(tableName = "site_settings") +data class SiteSettings( + /** The [Site] these settings belong to. */ + @PrimaryKey(autoGenerate = false) var siteId: Long = 0, + /** How often a validation attempt is made, in milliseconds. */ + var validationIntervalMs: Long, + /** The method of which is used to validate the [Site]. */ + var validationMode: ValidationMode, + /** Args that are used for the [ValidationMode], e.g. a search term. */ + var validationArgs: String?, + /** Whether or not the [Site] is enabled for automatic periodic checks. */ + var disabled: Boolean, + /** The network response timeout for validation attempts. */ + var networkTimeout: Int +) : Serializable { + + constructor() : this(0, 0, STATUS_CODE, null, false, 0) +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Status.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Status.kt new file mode 100644 index 0000000..4dcc3a0 --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/model/Status.kt @@ -0,0 +1,59 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.data.model + +import com.afollestad.nocknock.data.R.string +import com.afollestad.nocknock.data.model.Status.CHECKING +import com.afollestad.nocknock.data.model.Status.OK +import com.afollestad.nocknock.data.model.Status.WAITING + +/** + * Represents the current status of a [Site] - or whether or not the + * site passed its most recent check. + * + * @author Aidan Follestad (@afollestad) + */ +enum class Status(val value: Int) { + /** The site has not been validated yet, pending the background job. */ + WAITING(1), + /** The site is currently being validated. */ + CHECKING(2), + /** The most recent validation attempt passed. */ + OK(3), + /** The site did not pass a recent validation attempt. */ + ERROR(4); + + companion object { + fun fromValue(value: Int) = when (value) { + OK.value -> OK + WAITING.value -> WAITING + CHECKING.value -> CHECKING + ERROR.value -> ERROR + else -> throw IllegalArgumentException("Unknown status: $value") + } + } +} + +fun Status.textRes() = when (this) { + OK -> string.everything_checks_out + WAITING -> string.waiting + CHECKING -> string.checking_status + else -> 0 +} + +fun Status?.isPending() = this == WAITING || this == CHECKING + +fun Int.toSiteStatus() = Status.fromValue(this) diff --git a/data/src/main/java/com/afollestad/nocknock/data/ValidationMode.kt b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt similarity index 66% rename from data/src/main/java/com/afollestad/nocknock/data/ValidationMode.kt rename to data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt index b2150ff..7a461b0 100644 --- a/data/src/main/java/com/afollestad/nocknock/data/ValidationMode.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationMode.kt @@ -13,12 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.afollestad.nocknock.data +package com.afollestad.nocknock.data.model -/** @author Aidan Follestad (@afollestad) */ +/** + * Represents the validation mode of a [Site] - this is the type of + * check that is performed to get the site's current [Status]. + * + * @author Aidan Follestad (@afollestad) + */ enum class ValidationMode(val value: Int) { + /** The site is running normally if its status code is successful. */ STATUS_CODE(1), + /** The site is running normally if a piece of text is found in its response body. */ TERM_SEARCH(2), + /** The site is running normally if a block of given JavaScript executes successfully. */ JAVASCRIPT(3); companion object { @@ -39,6 +47,8 @@ enum class ValidationMode(val value: Int) { } } -fun Int.toValidationMode() = ValidationMode.fromValue(this) +fun Int.toValidationMode() = + ValidationMode.fromValue(this) -fun Int.indexToValidationMode() = ValidationMode.fromIndex(this) +fun Int.indexToValidationMode() = + ValidationMode.fromIndex(this) diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/ValidationResult.kt b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationResult.kt new file mode 100644 index 0000000..3b91214 --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/model/ValidationResult.kt @@ -0,0 +1,43 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("unused") + +package com.afollestad.nocknock.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.afollestad.nocknock.data.model.Status.OK +import java.io.Serializable + +/** + * Represents the most recent validation result for a [Site]. + * + * @author Aidan Follestad (@afollestad) + */ +@Entity(tableName = "validation_results") +data class ValidationResult( + /** The [Site] that this result belongs to. */ + @PrimaryKey(autoGenerate = false) var siteId: Long = 0, + /** The timestamp in milliseconds at which this attempt was made. */ + var timestampMs: Long, + /** The result of this validation attempt. */ + var status: Status, + /** If the attempt was not successful, why it was not successful. */ + var reason: String? +) : Serializable { + + constructor(): this(0, 0, OK, null) +} diff --git a/dependencies.gradle b/dependencies.gradle index 2879f24..66a58f3 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -15,7 +15,9 @@ ext.versions = [ dagger : '2.19', kotlin : '1.3.10', coroutines : '1.0.1', + androidx : '1.0.0', + room : '2.0.0', rxBinding : '3.0.0-alpha1', @@ -23,9 +25,10 @@ ext.versions = [ rxkPrefs : '1.2.0', timber : '4.7.1', - testRunner : '1.0.2', junit : '4.12', mockito : '2.23.0', mockitoKotlin : '2.0.0-RC1', - truth : '0.42' + truth : '0.42', + androidxTestRunner: '1.1.0', + androidxTest : '1.0.0', ] diff --git a/engine/build.gradle b/engine/build.gradle index 7267354..c26b99b 100644 --- a/engine/build.gradle +++ b/engine/build.gradle @@ -10,8 +10,6 @@ android { targetSdkVersion versions.compileSdk versionCode versions.publishVersionCode versionName versions.publishVersion - - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } } @@ -34,9 +32,6 @@ dependencies { testImplementation 'org.mockito:mockito-core:' + versions.mockito testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin testImplementation 'com.google.truth:truth:' + versions.truth - - androidTestImplementation 'com.android.support.test:runner:' + versions.testRunner - androidTestImplementation 'com.google.truth:truth:' + versions.truth } apply from: '../spotless.gradle' \ No newline at end of file diff --git a/engine/src/androidTest/java/com/afollestad/nocknock/engine/ServerModelStoreTest.kt b/engine/src/androidTest/java/com/afollestad/nocknock/engine/ServerModelStoreTest.kt deleted file mode 100644 index 0563db7..0000000 --- a/engine/src/androidTest/java/com/afollestad/nocknock/engine/ServerModelStoreTest.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.engine - -import android.app.Application -import android.support.test.runner.AndroidJUnit4 -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.CHECKING -import com.afollestad.nocknock.data.ServerStatus.ERROR -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.engine.db.RealServerModelStore -import com.google.common.truth.Truth.assertThat -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import android.support.test.InstrumentationRegistry.getTargetContext as context - -@RunWith(AndroidJUnit4::class) -class ServerModelStoreTest { - - private lateinit var store: RealServerModelStore - - @Before fun setup() { - store = RealServerModelStore(context().applicationContext as Application) - store.db() - .wipe() - } - - @Test fun get() = runBlocking { - // Put some fake data to retrieve - store.put(fakeModel(1)) - val model2 = store.put(fakeModel(2)) - - val model = store.get(2) - .single() - assertThat(model).isEqualTo(model2.copy(id = 2)) - } - - @Test fun getAll() = runBlocking { - // Put some fake data to retrieve - val model1 = store.put(fakeModel(1)) - val model2 = store.put(fakeModel(2)) - - val models = store.get() - assertThat(models.size).isEqualTo(2) - assertThat(models[0]).isEqualTo(model1.copy(id = 1)) - assertThat(models[1]).isEqualTo(model2.copy(id = 2)) - } - - @Test fun update() = runBlocking { - store.put( - ServerModel( - name = "Wakanda Forever", - url = "https://www.wakanda.gov", - status = ERROR, - checkInterval = 5, - lastCheck = 10, - reason = "Body doesn't contain your term.", - validationMode = TERM_SEARCH, - validationContent = "Vibranium", - disabled = false - ) - ) - store.put(fakeModel(2)) - - val originalModel1 = store.get(id = 1) - .single() - - val defaultJs = "var responseObj = JSON.parse(response);\\nreturn responseObj.success === true;" - val newModel1 = originalModel1.copy( - name = "HYDRA", - url = "https://www.hyrda.dict", - status = CHECKING, - checkInterval = 10, - lastCheck = 20, - reason = "Evaluation failed.", - validationMode = JAVASCRIPT, - validationContent = defaultJs, - disabled = true - ) - assertThat(store.update(newModel1)).isEqualTo(1) - - val newModels = store.get() - assertThat(newModels.size).isEqualTo(2) - assertThat(newModels.first()).isEqualTo(newModel1) - } - - @Test fun delete() = runBlocking { - // Put some fake data to delete - val model1 = store.put(fakeModel(1)) - val model2 = store.put(fakeModel(2)) - - assertThat(store.delete(model1)).isEqualTo(1) - - val newModels = store.get() - assertThat(newModels.single()).isEqualTo(model2) - } - - @Test fun deleteAll() = runBlocking { - // Put some fake data to delete - store.put(fakeModel(1)) - store.put(fakeModel(2)) - - store.deleteAll() - assertThat(store.get()).isEmpty() - } - - private fun fakeModel(index: Int) = ServerModel( - name = "Model $index", - url = "https://hello.com/$index", - validationMode = STATUS_CODE - ) -} diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.java similarity index 55% rename from app/src/main/java/com/afollestad/nocknock/di/MainModule.kt rename to engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.java index 4054fe4..3df7106 100644 --- a/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.java @@ -1,4 +1,4 @@ -/** +/* * Designed and developed by Aidan Follestad (@afollestad) * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,27 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.afollestad.nocknock.di +package com.afollestad.nocknock.engine; -import com.afollestad.nocknock.R -import com.afollestad.nocknock.ui.main.MainActivity -import com.afollestad.nocknock.utilities.qualifiers.AppIconRes -import com.afollestad.nocknock.utilities.qualifiers.MainActivityClass -import dagger.Module -import dagger.Provides -import javax.inject.Singleton +import com.afollestad.nocknock.engine.statuscheck.RealValidationManager; +import com.afollestad.nocknock.engine.statuscheck.ValidationManager; +import dagger.Binds; +import dagger.Module; +import javax.inject.Singleton; /** @author Aidan Follestad (@afollestad) */ @Module -open class MainModule { +public abstract class EngineModule { - @Provides + @Binds @Singleton - @AppIconRes - fun provideAppIconRes(): Int = R.mipmap.ic_launcher - - @Provides - @Singleton - @MainActivityClass - fun provideMainActivityClass(): Class<*> = MainActivity::class.java + abstract ValidationManager provideCheckStatusManager(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/statuscheck/BootReceiver.kt index 1095220..298d975 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/BootReceiver.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/BootReceiver.kt @@ -31,7 +31,7 @@ import timber.log.Timber.d as log /** @author Aidan Follestad (@afollestad) */ class BootReceiver : BroadcastReceiver() { - @Inject lateinit var checkStatusManager: CheckStatusManager + @Inject lateinit var checkStatusManager: ValidationManager override fun onReceive( context: Context, diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusJob.kt b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationJob.kt similarity index 59% rename from engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusJob.kt rename to engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationJob.kt index efa092d..307db18 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusJob.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationJob.kt @@ -18,17 +18,20 @@ package com.afollestad.nocknock.engine.statuscheck import android.app.job.JobParameters import android.app.job.JobService import android.content.Intent -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus -import com.afollestad.nocknock.data.ServerStatus.CHECKING -import com.afollestad.nocknock.data.ServerStatus.ERROR -import com.afollestad.nocknock.data.ServerStatus.OK -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.isPending +import com.afollestad.nocknock.data.AppDatabase +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.Status +import com.afollestad.nocknock.data.model.Status.CHECKING +import com.afollestad.nocknock.data.model.Status.ERROR +import com.afollestad.nocknock.data.model.Status.OK +import com.afollestad.nocknock.data.model.Status.WAITING +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.data.model.isPending +import com.afollestad.nocknock.data.getSite +import com.afollestad.nocknock.data.updateSite import com.afollestad.nocknock.engine.BuildConfig.APPLICATION_ID -import com.afollestad.nocknock.engine.db.ServerModelStore import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.utilities.ext.injector import com.afollestad.nocknock.utilities.js.JavaScript @@ -42,8 +45,12 @@ import java.lang.System.currentTimeMillis import javax.inject.Inject import timber.log.Timber.d as log -/** @author Aidan Follestad (@afollestad)*/ -class CheckStatusJob : JobService() { +/** + * The job which is sent to the system JobScheduler to perform site validation in the background. + * + * @author Aidan Follestad (@afollestad) + */ +class ValidationJob : JobService() { companion object { const val ACTION_STATUS_UPDATE = "$APPLICATION_ID.STATUS_UPDATE" @@ -52,47 +59,50 @@ class CheckStatusJob : JobService() { const val KEY_SITE_ID = "site.id" } - @Inject lateinit var modelStore: ServerModelStore - @Inject lateinit var checkStatusManager: CheckStatusManager + @Inject lateinit var database: AppDatabase + @Inject lateinit var checkStatusManager: ValidationManager @Inject lateinit var notificationManager: NockNotificationManager override fun onStartJob(params: JobParameters): Boolean { injector().injectInto(this) - val siteId = params.extras.getInt(KEY_SITE_ID) + val siteId = params.extras.getLong(KEY_SITE_ID) GlobalScope.launch(Main) { - val sites = async(IO) { modelStore.get(id = siteId) }.await() - if (sites.isEmpty()) { - log("Unable to find any sites for ID $siteId, this job will not be rescheduled.") + val site = async(IO) { database.getSite(siteId) }.await() + if (site == null) { + log("Unable to find a site for ID $siteId, this job will not be rescheduled.") return@launch jobFinished(params, false) } - val site = sites.single() + val siteSettings = site.settings + requireNotNull(siteSettings) { "Site settings must be populated." } + log("Performing status checks on site ${site.id}...") sendBroadcast(Intent(ACTION_JOB_RUNNING).apply { putExtra(KEY_SITE_ID, site.id) }) log("Checking ${site.name} (${site.url})...") - val result = async(IO) { + val jobResult = async(IO) { updateStatus(site, CHECKING) val checkResult = checkStatusManager.performCheck(site) val resultModel = checkResult.model val resultResponse = checkResult.response + val result = resultModel.lastResult!! - if (resultModel.status != OK) { - log("Got unsuccessful check status back: ${resultModel.reason}") + if (result.status != OK) { + log("Got unsuccessful check status back: ${result.reason}") return@async updateStatus(site = resultModel) } else { - when (site.validationMode) { + when (siteSettings.validationMode) { TERM_SEARCH -> { val body = resultResponse?.body()?.string() ?: "" log("Using TERM_SEARCH validation mode on body of length: ${body.length}") - return@async if (!body.contains(site.validationContent ?: "")) { + return@async if (!body.contains(siteSettings.validationArgs ?: "")) { updateStatus( - resultModel.copy( + resultModel.withStatus( status = ERROR, - reason = "Term \"${site.validationContent}\" not found in response body." + reason = "Term \"${siteSettings.validationArgs}\" not found in response body." ) ) } else { @@ -102,9 +112,9 @@ class CheckStatusJob : JobService() { JAVASCRIPT -> { val body = resultResponse?.body()?.string() ?: "" log("Using JAVASCRIPT validation mode on body of length: ${body.length}") - val reason = JavaScript.eval(resultModel.validationContent ?: "", body) + val reason = JavaScript.eval(siteSettings.validationArgs ?: "", body) return@async if (reason != null) { - updateStatus(resultModel.copy(reason = reason), status = ERROR) + updateStatus(resultModel.withStatus(reason = reason), status = ERROR) } else { resultModel } @@ -113,27 +123,29 @@ class CheckStatusJob : JobService() { // We already know the status code is successful because we are in this else branch log("Using STATUS_CODE validation, which has passed!") updateStatus( - resultModel.copy( + resultModel.withStatus( status = OK, reason = null ) ) } else -> { - throw IllegalArgumentException("Unknown validation mode: ${site.validationMode}") + throw IllegalArgumentException( + "Unknown validation mode: ${siteSettings.validationArgs}" + ) } } } }.await() - if (result.status == OK) { - notificationManager.cancelStatusNotification(result) + if (jobResult.lastResult!!.status == OK) { + notificationManager.cancelStatusNotification(jobResult) } else { - notificationManager.postStatusNotification(result) + notificationManager.postStatusNotification(jobResult) } checkStatusManager.scheduleCheck( - site = result, + site = jobResult, fromFinishingJob = true ) } @@ -148,28 +160,30 @@ class CheckStatusJob : JobService() { } private suspend fun updateStatus( - site: ServerModel, - status: ServerStatus = site.status - ): ServerModel { + site: Site, + status: Status = site.lastResult?.status ?: WAITING + ): Site { log("Updating ${site.name} (${site.url}) status to $status...") val lastCheckTime = - if (status.isPending()) site.lastCheck + if (status.isPending()) site.lastResult?.timestampMs ?: -1 else currentTimeMillis() val reason = if (status == OK) null - else site.reason + else site.lastResult?.reason ?: "Unknown" - val newSiteModel = site.copy( + val updatedModel = site.withStatus( status = status, - lastCheck = lastCheckTime, + timestamp = lastCheckTime, reason = reason ) - modelStore.update(newSiteModel) + database.updateSite(updatedModel) withContext(Main) { - sendBroadcast(Intent(ACTION_STATUS_UPDATE).apply { putExtra(KEY_UPDATE_MODEL, newSiteModel) }) + sendBroadcast(Intent(ACTION_STATUS_UPDATE).apply { + putExtra(KEY_UPDATE_MODEL, updatedModel) + }) } - return newSiteModel + return updatedModel } } diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationManager.kt similarity index 71% rename from engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt rename to engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationManager.kt index cc50105..8fcb288 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/CheckStatusManager.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/statuscheck/ValidationManager.kt @@ -17,12 +17,13 @@ package com.afollestad.nocknock.engine.statuscheck import android.app.job.JobScheduler import android.app.job.JobScheduler.RESULT_SUCCESS -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.ERROR -import com.afollestad.nocknock.data.ServerStatus.OK +import com.afollestad.nocknock.data.AppDatabase +import com.afollestad.nocknock.data.allSites +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.Status.ERROR +import com.afollestad.nocknock.data.model.Status.OK import com.afollestad.nocknock.engine.R -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_SITE_ID +import com.afollestad.nocknock.engine.statuscheck.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 @@ -37,37 +38,37 @@ import timber.log.Timber.d as log /** @author Aidan Follestad (@afollestad) */ data class CheckResult( - val model: ServerModel, + val model: Site, val response: Response? = null ) typealias ClientTimeoutChanger = (client: OkHttpClient, timeout: Int) -> OkHttpClient /** @author Aidan Follestad (@afollestad) */ -interface CheckStatusManager { +interface ValidationManager { suspend fun ensureScheduledChecks() fun scheduleCheck( - site: ServerModel, + site: Site, rightNow: Boolean = false, cancelPrevious: Boolean = rightNow, fromFinishingJob: Boolean = false ) - fun cancelCheck(site: ServerModel) + fun cancelCheck(site: Site) - suspend fun performCheck(site: ServerModel): CheckResult + suspend fun performCheck(site: Site): CheckResult } -class RealCheckStatusManager @Inject constructor( +class RealValidationManager @Inject constructor( private val jobScheduler: JobScheduler, private val okHttpClient: OkHttpClient, private val stringProvider: StringProvider, private val bundleProvider: BundleProvider, private val jobInfoProvider: JobInfoProvider, - private val siteStore: ServerModelStore -) : CheckStatusManager { + private val database: AppDatabase +) : ValidationManager { private var clientTimeoutChanger: ClientTimeoutChanger = { client, timeout -> client.newBuilder() @@ -76,12 +77,12 @@ class RealCheckStatusManager @Inject constructor( } override suspend fun ensureScheduledChecks() { - val sites = siteStore.get() + val sites = database.allSites() if (sites.isEmpty()) { return } log("Ensuring enabled sites have scheduled checks.") - sites.filter { !it.disabled } + sites.filter { it.settings?.disabled != true } .forEach { site -> val existingJob = jobForSite(site) if (existingJob == null) { @@ -94,12 +95,15 @@ class RealCheckStatusManager @Inject constructor( } override fun scheduleCheck( - site: ServerModel, + site: Site, rightNow: Boolean, cancelPrevious: Boolean, fromFinishingJob: Boolean ) { - check(site.id != 0) { "Cannot schedule checks for jobs with no ID." } + check(site.id != 0L) { "Cannot schedule checks for jobs with no ID." } + val siteSettings = site.settings + requireNotNull(siteSettings) { "Site settings must be populated." } + if (cancelPrevious) { cancelCheck(site) } else if (!fromFinishingJob) { @@ -111,18 +115,18 @@ class RealCheckStatusManager @Inject constructor( log("Requesting a check job for site to be scheduled: $site") val extras = bundleProvider.createPersistable { - putInt(KEY_SITE_ID, site.id) + putLong(KEY_SITE_ID, site.id) } val jobInfo = jobInfoProvider.createCheckJob( - id = site.id, + id = site.id.toInt(), onlyUnmeteredNetwork = false, delayMs = if (rightNow) { 1 } else { - site.checkInterval + siteSettings.validationIntervalMs }, extras = extras, - target = CheckStatusJob::class.java + target = ValidationJob::class.java ) val dispatchResult = jobScheduler.schedule(jobInfo) @@ -133,15 +137,17 @@ class RealCheckStatusManager @Inject constructor( } } - override fun cancelCheck(site: ServerModel) { - check(site.id != 0) { "Cannot cancel scheduled checks for jobs with no ID." } + override fun cancelCheck(site: Site) { + check(site.id != 0L) { "Cannot cancel scheduled checks for jobs with no ID." } log("Cancelling scheduled checks for site: ${site.id}") - jobScheduler.cancel(site.id) + jobScheduler.cancel(site.id.toInt()) } - override suspend fun performCheck(site: ServerModel): CheckResult { - check(site.id != 0) { "Cannot schedule checks for jobs with no ID." } - check(site.networkTimeout > 0) { "Network timeout not set for site ${site.id}" } + override suspend fun performCheck(site: Site): CheckResult { + check(site.id != 0L) { "Cannot schedule checks for jobs with no ID." } + val siteSettings = site.settings + requireNotNull(siteSettings) { "Site settings must be populated." } + check(siteSettings.networkTimeout > 0) { "Network timeout not set for site ${site.id}" } log("performCheck(${site.id}) - GET ${site.url}") val request = Request.Builder() @@ -150,20 +156,20 @@ class RealCheckStatusManager @Inject constructor( .build() return try { - val client = clientTimeoutChanger(okHttpClient, site.networkTimeout) + val client = clientTimeoutChanger(okHttpClient, siteSettings.networkTimeout) val response = client.newCall(request) .execute() if (response.isSuccessful || response.code() == 401) { log("performCheck(${site.id}) = Successful") CheckResult( - model = site.copy(status = OK, reason = null), + model = site.withStatus(status = OK, reason = null), response = response ) } else { log("performCheck(${site.id}) = Failure, HTTP code ${response.code()}") CheckResult( - model = site.copy( + model = site.withStatus( status = ERROR, reason = "Response ${response.code()} - ${response.body()?.string() ?: "Unknown"}" ), @@ -173,20 +179,20 @@ class RealCheckStatusManager @Inject constructor( } catch (timeoutEx: SocketTimeoutException) { log("performCheck(${site.id}) = Socket Timeout") CheckResult( - model = site.copy( + model = site.withStatus( status = ERROR, reason = stringProvider.get(R.string.timeout) ) ) } catch (ex: Exception) { log("performCheck(${site.id}) = Error: ${ex.message}") - CheckResult(model = site.copy(status = ERROR, reason = ex.message)) + CheckResult(model = site.withStatus(status = ERROR, reason = ex.message)) } } - private fun jobForSite(site: ServerModel) = + private fun jobForSite(site: Site) = jobScheduler.allPendingJobs - .firstOrNull { job -> job.id == site.id } + .firstOrNull { job -> job.id == site.id.toInt() } @TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) { this.clientTimeoutChanger = changer 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 a97d034..071e407 100644 --- a/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt +++ b/engine/src/test/java/com/afollestad/nocknock/engine/CheckStatusManagerTest.kt @@ -17,13 +17,13 @@ package com.afollestad.nocknock.engine import android.app.job.JobInfo import android.app.job.JobScheduler -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.ERROR -import com.afollestad.nocknock.data.ServerStatus.OK -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_SITE_ID -import com.afollestad.nocknock.engine.statuscheck.RealCheckStatusManager +import com.afollestad.nocknock.data.legacy.ServerModel +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.utilities.providers.StringProvider import com.google.common.truth.Truth.assertThat import com.nhaarman.mockitokotlin2.any @@ -58,7 +58,7 @@ class CheckStatusManagerTest { private val jobInfoProvider = testJobInfoProvider() private val store = mock() - private val manager = RealCheckStatusManager( + private val manager = RealValidationManager( jobScheduler, okHttpClient, stringProvider, diff --git a/notifications/build.gradle b/notifications/build.gradle index 9578137..e843285 100644 --- a/notifications/build.gradle +++ b/notifications/build.gradle @@ -14,7 +14,6 @@ android { } dependencies { - implementation project(':data') implementation project(':utilities') api 'androidx.appcompat:appcompat:' + versions.androidx diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt index 73300dc..35acd46 100644 --- a/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt +++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/NockNotificationManager.kt @@ -18,9 +18,9 @@ package com.afollestad.nocknock.notifications import android.annotation.TargetApi import android.app.NotificationManager import android.os.Build.VERSION_CODES -import com.afollestad.nocknock.data.ServerModel import com.afollestad.nocknock.notifications.Channel.CheckFailures import com.afollestad.nocknock.utilities.providers.BitmapProvider +import com.afollestad.nocknock.utilities.providers.CanNotifyModel import com.afollestad.nocknock.utilities.providers.IntentProvider import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider import com.afollestad.nocknock.utilities.providers.NotificationProvider @@ -37,9 +37,9 @@ interface NockNotificationManager { fun createChannels() - fun postStatusNotification(model: ServerModel) + fun postStatusNotification(model: CanNotifyModel) - fun cancelStatusNotification(model: ServerModel) + fun cancelStatusNotification(model: CanNotifyModel) fun cancelStatusNotifications() } @@ -65,32 +65,32 @@ class RealNockNotificationManager @Inject constructor( override fun createChannels() = Channel.values().forEach(this::createChannel) - override fun postStatusNotification(model: ServerModel) { + override fun postStatusNotification(model: CanNotifyModel) { if (isAppOpen) { // Don't show notifications while the app is open - log("App is open, status notification for site ${model.id} won't be posted.") + log("App is open, status notification for site ${model.notiId()} won't be posted.") return } - log("Posting status notification for site ${model.id}...") + log("Posting status notification for site ${model.notiId()}...") val intent = intentProvider.getPendingIntentForViewSite(model) val newNotification = notificationProvider.create( channelId = CheckFailures.id, - title = model.name, + title = model.notiName(), content = stringProvider.get(R.string.something_wrong), intent = intent, smallIcon = R.drawable.ic_notification, largeIcon = bitmapProvider.get(appIconRes) ) - stockManager.notify(model.url, model.notificationId(), newNotification) + stockManager.notify(model.notiTag(), model.notificationId(), newNotification) log("Posted status notification for site ${model.notificationId()}.") } - override fun cancelStatusNotification(model: ServerModel) { + override fun cancelStatusNotification(model: CanNotifyModel) { stockManager.cancel(model.notificationId()) - log("Cancelled status notification for site ${model.id}.") + log("Cancelled status notification for site ${model.notiId()}.") } override fun cancelStatusNotifications() = stockManager.cancelAll() @@ -107,5 +107,5 @@ class RealNockNotificationManager @Inject constructor( log("Created notification channel ${channel.id}") } - private fun ServerModel.notificationId() = BASE_NOTIFICATION_REQUEST_CODE + this.id + private fun CanNotifyModel.notificationId() = BASE_NOTIFICATION_REQUEST_CODE + this.notiId() } diff --git a/notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.kt b/notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.java similarity index 70% rename from notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.kt rename to notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.java index a5212bd..d43d339 100644 --- a/notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.kt +++ b/notifications/src/main/java/com/afollestad/nocknock/notifications/NotificationsModule.java @@ -1,4 +1,4 @@ -/** +/* * Designed and developed by Aidan Follestad (@afollestad) * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,19 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.afollestad.nocknock.notifications +package com.afollestad.nocknock.notifications; -import dagger.Binds -import dagger.Module -import javax.inject.Singleton +import dagger.Binds; +import dagger.Module; +import javax.inject.Singleton; /** @author Aidan Follestad (@afollestad) */ @Module -abstract class NotificationsModule { +public abstract class NotificationsModule { @Binds @Singleton - abstract fun provideNockNotificationManager( - notificationManager: RealNockNotificationManager - ): NockNotificationManager + abstract NockNotificationManager provideNockNotificationManager( + RealNockNotificationManager notificationManager); } diff --git a/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt b/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt index c050eea..de614fa 100644 --- a/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt +++ b/notifications/src/test/java/com/afollestad/nocknock/notifications/NockNotificationManagerTest.kt @@ -20,8 +20,8 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.graphics.Bitmap -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.legacy.ServerModel +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE import com.afollestad.nocknock.notifications.Channel.CheckFailures import com.afollestad.nocknock.utilities.providers.BitmapProvider import com.afollestad.nocknock.utilities.providers.IntentProvider diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.java similarity index 59% rename from utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt rename to utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.java index a2a9770..5861004 100644 --- a/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.kt +++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/UtilitiesModule.java @@ -1,11 +1,11 @@ -/** +/* * 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 + * 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, @@ -13,77 +13,63 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.afollestad.nocknock.utilities +package com.afollestad.nocknock.utilities; -import com.afollestad.nocknock.utilities.providers.BitmapProvider -import com.afollestad.nocknock.utilities.providers.BundleProvider -import com.afollestad.nocknock.utilities.providers.IntentProvider -import com.afollestad.nocknock.utilities.providers.JobInfoProvider -import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider -import com.afollestad.nocknock.utilities.providers.NotificationProvider -import com.afollestad.nocknock.utilities.providers.RealBitmapProvider -import com.afollestad.nocknock.utilities.providers.RealBundleProvider -import com.afollestad.nocknock.utilities.providers.RealIntentProvider -import com.afollestad.nocknock.utilities.providers.RealJobInfoProvider -import com.afollestad.nocknock.utilities.providers.RealNotificationChannelProvider -import com.afollestad.nocknock.utilities.providers.RealNotificationProvider -import com.afollestad.nocknock.utilities.providers.RealSdkProvider -import com.afollestad.nocknock.utilities.providers.RealStringProvider -import com.afollestad.nocknock.utilities.providers.SdkProvider -import com.afollestad.nocknock.utilities.providers.StringProvider -import dagger.Binds -import dagger.Module -import javax.inject.Singleton +import com.afollestad.nocknock.utilities.providers.BitmapProvider; +import com.afollestad.nocknock.utilities.providers.BundleProvider; +import com.afollestad.nocknock.utilities.providers.IntentProvider; +import com.afollestad.nocknock.utilities.providers.JobInfoProvider; +import com.afollestad.nocknock.utilities.providers.NotificationChannelProvider; +import com.afollestad.nocknock.utilities.providers.NotificationProvider; +import com.afollestad.nocknock.utilities.providers.RealBitmapProvider; +import com.afollestad.nocknock.utilities.providers.RealBundleProvider; +import com.afollestad.nocknock.utilities.providers.RealIntentProvider; +import com.afollestad.nocknock.utilities.providers.RealJobInfoProvider; +import com.afollestad.nocknock.utilities.providers.RealNotificationChannelProvider; +import com.afollestad.nocknock.utilities.providers.RealNotificationProvider; +import com.afollestad.nocknock.utilities.providers.RealSdkProvider; +import com.afollestad.nocknock.utilities.providers.RealStringProvider; +import com.afollestad.nocknock.utilities.providers.SdkProvider; +import com.afollestad.nocknock.utilities.providers.StringProvider; +import dagger.Binds; +import dagger.Module; +import javax.inject.Singleton; /** @author Aidan Follestad (@afollestad) */ @Module -abstract class UtilitiesModule { +public abstract class UtilitiesModule { @Binds @Singleton - abstract fun provideSdkProvider( - sdkProvider: RealSdkProvider - ): SdkProvider + abstract SdkProvider provideSdkProvider(RealSdkProvider sdkProvider); @Binds @Singleton - abstract fun provideBitmapProvider( - bitmapProvider: RealBitmapProvider - ): BitmapProvider + abstract BitmapProvider provideBitmapProvider(RealBitmapProvider bitmapProvider); @Binds @Singleton - abstract fun provideStringProvider( - stringProvider: RealStringProvider - ): StringProvider + abstract StringProvider provideStringProvider(RealStringProvider stringProvider); @Binds @Singleton - abstract fun provideIntentProvider( - intentProvider: RealIntentProvider - ): IntentProvider + abstract IntentProvider provideIntentProvider(RealIntentProvider intentProvider); @Binds @Singleton - abstract fun provideChannelProvider( - channelProvider: RealNotificationChannelProvider - ): NotificationChannelProvider + abstract NotificationChannelProvider provideChannelProvider( + RealNotificationChannelProvider channelProvider); @Binds @Singleton - abstract fun provideNotificationProvider( - notificationProvider: RealNotificationProvider - ): NotificationProvider + abstract NotificationProvider provideNotificationProvider( + RealNotificationProvider notificationProvider); @Binds @Singleton - abstract fun provideBundleProvider( - bundleProvider: RealBundleProvider - ): BundleProvider + abstract BundleProvider provideBundleProvider(RealBundleProvider bundleProvider); @Binds @Singleton - abstract fun provideJobInfoProvider( - jobInfoProvider: RealJobInfoProvider - ): JobInfoProvider + abstract JobInfoProvider provideJobInfoProvider(RealJobInfoProvider jobInfoProvider); } diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContentValuesExt.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContentValuesExt.kt deleted file mode 100644 index b13dfd0..0000000 --- a/utilities/src/main/java/com/afollestad/nocknock/utilities/ext/ContentValuesExt.kt +++ /dev/null @@ -1,59 +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.utilities.ext - -import android.content.ContentValues - -/** - * Returns a [ContentValues] instance which contains only values that have changed between - * the receiver (original) and parameter (new) instances. - */ -fun ContentValues.diffFrom(contentValues: ContentValues): ContentValues { - val diff = ContentValues() - for ((name, oldValue) in this.valueSet()) { - val newValue = contentValues.get(name) - if (newValue != oldValue) { - diff.putAny(name, newValue) - } - } - return diff -} - -/** - * Auto casts an [Any] value and uses the appropriate `put` method to store it - * in the [ContentValues] instance. - */ -fun ContentValues.putAny( - name: String, - value: Any? -) { - if (value == null) { - putNull(name) - return - } - when (value) { - is String -> put(name, value) - is Byte -> put(name, value) - is Short -> put(name, value) - is Int -> put(name, value) - is Long -> put(name, value) - is Float -> put(name, value) - is Double -> put(name, value) - is Boolean -> put(name, value) - is ByteArray -> put(name, value) - else -> throw IllegalArgumentException("ContentValues can't hold $value") - } -} diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt index 8fc655b..3d60e81 100644 --- a/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt +++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/BundleProvider.kt @@ -19,9 +19,9 @@ import android.os.PersistableBundle import javax.inject.Inject interface IBundle { - fun putInt( + fun putLong( key: String, - value: Int + value: Long ) } @@ -30,7 +30,7 @@ typealias IBundler = IBundle.() -> Unit /** @author Aidan Follestad (@afollestad) */ interface BundleProvider { - fun createPersistable(builder: IBundle.() -> Unit): PersistableBundle + fun createPersistable(bundler: IBundle.() -> Unit): PersistableBundle } /** @author Aidan Follestad (@afollestad) */ @@ -39,10 +39,10 @@ class RealBundleProvider @Inject constructor() : BundleProvider { override fun createPersistable(bundler: IBundler): PersistableBundle { val realBundle = PersistableBundle() bundler(object : IBundle { - override fun putInt( + override fun putLong( key: String, - value: Int - ) = realBundle.putInt(key, value) + value: Long + ) = realBundle.putLong(key, value) }) return realBundle } diff --git a/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt index cee9298..56f65a0 100644 --- a/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt +++ b/utilities/src/main/java/com/afollestad/nocknock/utilities/providers/IntentProvider.kt @@ -24,16 +24,20 @@ import java.io.Serializable import javax.inject.Inject /** @author Aidan Follestad (@afollestad) */ -interface IdProvider : Serializable { +interface CanNotifyModel : Serializable { - fun id(): Int + fun notiId(): Int + + fun notiName(): String + + fun notiTag(): String } /** @author Aidan Follestad (@afollestad) */ interface IntentProvider { fun getPendingIntentForViewSite( - model: IdProvider + model: CanNotifyModel ): PendingIntent } @@ -48,17 +52,17 @@ class RealIntentProvider @Inject constructor( const val KEY_VIEW_NOTIFICATION_MODEL = "model" } - override fun getPendingIntentForViewSite(model: IdProvider): PendingIntent { + override fun getPendingIntentForViewSite(model: CanNotifyModel): PendingIntent { val openIntent = getIntentForViewSite(model) return PendingIntent.getActivity( app, - BASE_NOTIFICATION_REQUEST_CODE + model.id(), + BASE_NOTIFICATION_REQUEST_CODE + model.notiId(), openIntent, FLAG_CANCEL_CURRENT ) } - private fun getIntentForViewSite(model: IdProvider) = + private fun getIntentForViewSite(model: CanNotifyModel) = Intent(app, mainActivity).apply { putExtra(KEY_VIEW_NOTIFICATION_MODEL, model) } 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 1e45e83..5596d4c 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/LoadingIndicatorFrame.kt @@ -16,6 +16,7 @@ package com.afollestad.nocknock.viewcomponents import android.content.Context +import android.os.Handler import android.util.AttributeSet import android.widget.FrameLayout import androidx.core.content.ContextCompat @@ -32,6 +33,7 @@ class LoadingIndicatorFrame( } private val showRunnable = Runnable { show() } + private val delayHandler = Handler() init { setBackgroundColor(ContextCompat.getColor(context, R.color.loading_indicator_frame_background)) @@ -42,11 +44,11 @@ class LoadingIndicatorFrame( } fun setLoading() { - handler.postDelayed(showRunnable, SHOW_DELAY_MS) + delayHandler.postDelayed(showRunnable, SHOW_DELAY_MS) } fun setDone() { - handler.removeCallbacks(showRunnable) + delayHandler.removeCallbacks(showRunnable) hide() } } diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/StatusImageView.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/StatusImageView.kt index 9f907e6..96eb3cb 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/StatusImageView.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/StatusImageView.kt @@ -18,11 +18,11 @@ package com.afollestad.nocknock.viewcomponents import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView -import com.afollestad.nocknock.data.ServerStatus -import com.afollestad.nocknock.data.ServerStatus.CHECKING -import com.afollestad.nocknock.data.ServerStatus.ERROR -import com.afollestad.nocknock.data.ServerStatus.OK -import com.afollestad.nocknock.data.ServerStatus.WAITING +import com.afollestad.nocknock.data.model.Status +import com.afollestad.nocknock.data.model.Status.CHECKING +import com.afollestad.nocknock.data.model.Status.ERROR +import com.afollestad.nocknock.data.model.Status.OK +import com.afollestad.nocknock.data.model.Status.WAITING /** @author Aidan Follestad (@afollestad) */ class StatusImageView( @@ -34,7 +34,7 @@ class StatusImageView( setStatus(OK) } - fun setStatus(status: ServerStatus) = when (status) { + fun setStatus(status: Status) = when (status) { CHECKING, WAITING -> { setImageResource(R.drawable.status_progress) setBackgroundResource(R.drawable.yellow_circle)