Switch from SQLite to Room

This commit is contained in:
Aidan Follestad 2018-12-05 13:30:04 -08:00
parent cad589eebc
commit 88ae30c0c9
61 changed files with 2066 additions and 928 deletions

View file

@ -18,9 +18,9 @@ android {
}
dependencies {
implementation project(':data')
implementation project(':utilities')
implementation project(':engine')
implementation project(':data')
implementation project(':notifications')
implementation project(':viewcomponents')

View file

@ -42,7 +42,7 @@
android:windowSoftInputMode="stateHidden"/>
<service
android:name=".engine.statuscheck.CheckStatusJob"
android:name=".engine.statuscheck.ValidationJob"
android:label="@string/check_service_name"
android:permission="android.permission.BIND_JOB_SERVICE"/>

View file

@ -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")
}

View file

@ -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<ServerVH>() {
class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<SiteViewHolder>() {
private val models = mutableListOf<ServerModel>()
private val models = mutableListOf<Site>()
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<Serve
private fun update(
index: Int,
model: ServerModel
model: Site
) {
models[index] = model
notifyItemChanged(index)
@ -120,7 +129,7 @@ class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<Serve
notifyItemRemoved(index)
}
fun remove(target: ServerModel) {
fun remove(target: Site) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
remove(i)
@ -129,7 +138,7 @@ class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<Serve
}
}
fun set(newModels: List<ServerModel>) {
fun set(newModels: List<Site>) {
this.models.clear()
if (!newModels.isEmpty()) {
this.models.addAll(newModels)
@ -140,14 +149,14 @@ class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<Serve
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ServerVH {
): SiteViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_server, parent, false)
return ServerVH(v, this)
return SiteViewHolder(v, this)
}
override fun onBindViewHolder(
holder: ServerVH,
holder: SiteViewHolder,
position: Int
) {
val model = models[position]

View file

@ -0,0 +1,71 @@
/*
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.di;
import android.app.Application;
import android.app.NotificationManager;
import android.app.job.JobScheduler;
import com.afollestad.nocknock.NockNockApp;
import com.afollestad.nocknock.engine.EngineModule;
import com.afollestad.nocknock.engine.statuscheck.BootReceiver;
import com.afollestad.nocknock.engine.statuscheck.ValidationJob;
import com.afollestad.nocknock.notifications.NotificationsModule;
import com.afollestad.nocknock.ui.addsite.AddSiteActivity;
import com.afollestad.nocknock.ui.main.MainActivity;
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity;
import com.afollestad.nocknock.utilities.UtilitiesModule;
import dagger.BindsInstance;
import dagger.Component;
import javax.inject.Singleton;
import okhttp3.OkHttpClient;
/** @author Aidan Follestad (@afollestad) */
@Singleton
@Component(
modules = {MainModule.class, EngineModule.class, NotificationsModule.class, UtilitiesModule.class}
)
public interface AppComponent {
void inject(NockNockApp app);
void inject(MainActivity activity);
void inject(ViewSiteActivity activity);
void inject(AddSiteActivity activity);
void inject(ValidationJob job);
void inject(BootReceiver bootReceiver);
@Component.Builder
interface Builder {
@BindsInstance
Builder application(Application application);
@BindsInstance
Builder okHttpClient(OkHttpClient okHttpClient);
@BindsInstance
Builder jobScheduler(JobScheduler jobScheduler);
@BindsInstance
Builder notificationManager(NotificationManager notificationManager);
AppComponent build();
}
}

View file

@ -1,73 +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.di
import android.app.Application
import android.app.NotificationManager
import android.app.job.JobScheduler
import com.afollestad.nocknock.NockNockApp
import com.afollestad.nocknock.engine.EngineModule
import com.afollestad.nocknock.engine.statuscheck.BootReceiver
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
import com.afollestad.nocknock.notifications.NotificationsModule
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.UtilitiesModule
import dagger.BindsInstance
import dagger.Component
import okhttp3.OkHttpClient
import javax.inject.Singleton
/** @author Aidan Follestad (@afollestad) */
@Singleton
@Component(
modules = [
MainModule::class,
MainBindModule::class,
EngineModule::class,
NotificationsModule::class,
UtilitiesModule::class
]
)
interface AppComponent {
fun inject(app: NockNockApp)
fun inject(activity: MainActivity)
fun inject(activity: ViewSiteActivity)
fun inject(activity: AddSiteActivity)
fun inject(job: CheckStatusJob)
fun inject(bootReceiver: BootReceiver)
@Component.Builder
interface Builder {
@BindsInstance fun application(application: Application): Builder
@BindsInstance fun okHttpClient(okHttpClient: OkHttpClient): Builder
@BindsInstance fun jobScheduler(jobScheduler: JobScheduler): Builder
@BindsInstance fun notificationManager(notificationManager: NotificationManager): Builder
fun build(): AppComponent
}
}

View file

@ -1,49 +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.di
import com.afollestad.nocknock.ui.addsite.AddSitePresenter
import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter
import com.afollestad.nocknock.ui.main.MainPresenter
import com.afollestad.nocknock.ui.main.RealMainPresenter
import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter
import com.afollestad.nocknock.ui.viewsite.ViewSitePresenter
import dagger.Binds
import dagger.Module
import javax.inject.Singleton
/** @author Aidan Follestad (@afollestad) */
@Module
abstract class MainBindModule {
@Binds
@Singleton
abstract fun provideMainPresenter(
presenter: RealMainPresenter
): MainPresenter
@Binds
@Singleton
abstract fun provideAddSitePresenter(
presenter: RealAddSitePresenter
): AddSitePresenter
@Binds
@Singleton
abstract fun provideViewSitePresenter(
presenter: RealViewSitePresenter
): ViewSitePresenter
}

View file

@ -0,0 +1,74 @@
/*
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.di;
import static androidx.room.Room.databaseBuilder;
import android.app.Application;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.data.AppDatabase;
import com.afollestad.nocknock.ui.addsite.AddSitePresenter;
import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter;
import com.afollestad.nocknock.ui.main.MainActivity;
import com.afollestad.nocknock.ui.main.MainPresenter;
import com.afollestad.nocknock.ui.main.RealMainPresenter;
import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter;
import com.afollestad.nocknock.ui.viewsite.ViewSitePresenter;
import com.afollestad.nocknock.utilities.qualifiers.AppIconRes;
import com.afollestad.nocknock.utilities.qualifiers.MainActivityClass;
import dagger.Binds;
import dagger.Module;
import dagger.Provides;
import javax.inject.Singleton;
/** @author Aidan Follestad (@afollestad) */
@Module
abstract class MainModule {
@SuppressWarnings("FieldCanBeLocal")
private static String DATABASE_NAME = "NockNock.db";
@Binds
@Singleton
abstract MainPresenter provideMainPresenter(RealMainPresenter presenter);
@Binds
@Singleton
abstract AddSitePresenter provideAddSitePresenter(RealAddSitePresenter presenter);
@Binds
@Singleton
abstract ViewSitePresenter provideViewSitePresenter(RealViewSitePresenter presenter);
@Provides
@Singleton
@AppIconRes
static int provideAppIconRes() {
return R.mipmap.ic_launcher;
}
@Provides
@Singleton
@MainActivityClass
static Class<?> provideMainActivityClass() {
return MainActivity.class;
}
@Provides
@Singleton
static AppDatabase provideAppDatabase(Application app) {
return databaseBuilder(app, AppDatabase.class, DATABASE_NAME).build();
}
}

View file

@ -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)
)
}

View file

@ -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(

View file

@ -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<ServerModel>) {
override fun setLoading() = loadingProgress.setLoading()
override fun setDoneLoading() = loadingProgress.setDone()
override fun setModels(models: List<Site>) {
list.post {
adapter.set(models)
emptyText.showOrHide(models.isEmpty())
}
}
override fun updateModel(model: 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) {

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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<ServerModel>)
fun setLoading()
fun updateModel(model: ServerModel)
fun setDoneLoading()
fun onSiteDeleted(model: ServerModel)
fun setModels(models: List<Site>)
fun updateModel(model: Site)
fun onSiteDeleted(model: Site)
fun scopeWhileAttached(
context: CoroutineContext,

View file

@ -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
)

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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)

View file

@ -48,4 +48,12 @@
app:rippleColor="#40ffffff"
/>
<com.afollestad.nocknock.viewcomponents.LoadingIndicatorFrame
android:id="@+id/loadingProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
/>
</FrameLayout>

View file

@ -22,33 +22,32 @@
<string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</string>
<string name="please_enter_valid_url">Please enter a valid URL.</string>
<string name="please_enter_check_interval">Please input a check interval.</string>
<string name="please_enter_check_interval">Please input a validation interval.</string>
<string name="please_enter_search_term">Please input a search term.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
<string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string>
<string name="options">Options</string>
<string name="already_checking_sites">Already checking sites!</string>
<string name="remove_site">Remove Site</string>
<string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string>
<string name="remove">Remove</string>
<string name="save_changes">Save Changes</string>
<string name="view_site">View Site</string>
<string name="last_check_result">Last Check Result</string>
<string name="next_check">Next Check</string>
<string name="next_check_x">Next Check: %1$s</string>
<string name="last_check_result">Last Validation Result</string>
<string name="next_check">Next Validation</string>
<string name="next_check_x">Next Validation: %1$s</string>
<string name="now">Now</string>
<string name="none_turned_off">None (turned off)</string>
<string name="none">None</string>
<string name="disable_automatic_checks">Disable Automatic Checks</string>
<string name="disable_automatic_checks">Disable Automatic Validation</string>
<string name="disable_automatic_checks_prompt"><![CDATA[
Disable automatic checks for <b>%1$s</b>? 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 <b>%1$s</b>? 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.
]]></string>
<string name="disable">Disable</string>
<string name="renable_and_save_changes">Enable Checks &amp; Save Changes</string>
<string name="renable_and_save_changes">Enable Auto Validation &amp; Save Changes</string>
<string name="response_timeout">Network Response Timeout</string>
<string name="response_timeout_default">10000</string>
@ -56,22 +55,22 @@
<string name="refresh_status">Refresh Status</string>
<string name="warning_http_url">
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.
</string>
<string name="response_validation_mode">Response Validation Mode</string>
<string name="search_term">Search term…</string>
<string name="validation_mode_status_desc">
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.
</string>
<string name="validation_mode_term_desc">
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.
</string>
<string name="validation_mode_javascript_desc">
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.
</string>

View file

@ -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<ServerModelStore> {
on { runBlocking { put(any()) } } doAnswer { inv ->
inv.getArgument<ServerModel>(0)
}
}
private val checkStatusManager = mock<CheckStatusManager>()
private val database = mockDatabase()
private val checkStatusManager = mock<ValidationManager>()
private val view = mock<AddSiteView>()
private val presenter = RealAddSitePresenter(
serverModelStore,
database,
checkStatusManager
)
@ -260,10 +256,21 @@ class AddSitePresenterTest {
60000
)
val modelCaptor = argumentCaptor<ServerModel>()
val siteCaptor = argumentCaptor<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>()
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()
}

View file

@ -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<ServerModelStore>()
private val prefs = mock<SharedPreferences> {
on { getBoolean("did_db_migration", false) } doReturn true
}
private val app = mock<Application> {
on { getSharedPreferences("settings", MODE_PRIVATE) } doReturn prefs
}
private val database = mockDatabase()
private val notificationManager = mock<NockNotificationManager>()
private val checkStatusManager = mock<CheckStatusManager>()
private val checkStatusManager = mock<ValidationManager>()
private val view = mock<MainView>()
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<List<ServerModel>>()
val modelsCaptor = argumentCaptor<List<Site>>()
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

View file

@ -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<SiteDao> {
on { insert(isA()) } doReturn 1
on { one(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(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<SiteSettingsDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(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<ValidationResultsDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(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
}
}

View file

@ -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<ServerModelStore> {
on { runBlocking { put(any()) } } doAnswer { inv ->
inv.getArgument<ServerModel>(0)
}
}
private val checkStatusManager = mock<CheckStatusManager>()
private val database = mockDatabase()
private val checkStatusManager = mock<ValidationManager>()
private val notificationManager = mock<NockNotificationManager>()
private val view = mock<ViewSiteView>()
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<ServerModel>()
verify(view).setLoading()
verify(serverModelStore).update(modelCaptor.capture())
val siteCaptor = argumentCaptor<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>()
val resultCaptor = argumentCaptor<ValidationResult>()
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<ServerModel>()
verify(serverModelStore).update(modelCaptor.capture())
val modelCaptor = argumentCaptor<Site>()
val settingsCaptor = argumentCaptor<SiteSettings>()
val resultCaptor = argumentCaptor<ValidationResult>()
verify(database.siteDao()).update(modelCaptor.capture())
verify(database.siteSettingsDao()).update(settingsCaptor.capture())
verify(database.validationResultsDao()).update(resultCaptor.capture())
val newModel = modelCaptor.firstValue
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

View file

@ -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'

View file

@ -0,0 +1,10 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.afollestad.nocknock.data">
<application>
<uses-library
android:name="android.test.runner"
android:required="false"/>
</application>
</manifest>

View file

@ -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<Context>()
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!!)
}
}

View file

@ -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)

View file

@ -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<Site> {
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)
}

View file

@ -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

View file

@ -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<Site>
@Binds
@Singleton
abstract fun provideCheckStatusManager(
checkStatusManager: RealCheckStatusManager
): CheckStatusManager
@Query("SELECT * FROM sites WHERE id = :id LIMIT 1")
fun one(id: Long): List<Site>
@Insert(onConflict = FAIL)
fun insert(site: Site): Long
@Update(onConflict = FAIL)
fun update(site: Site): Int
@Delete
fun delete(site: Site): Int
}

View file

@ -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<SiteSettings>
@Query("SELECT * FROM site_settings WHERE siteId = :siteId LIMIT 1")
fun forSite(siteId: Long): List<SiteSettings>
@Insert(onConflict = FAIL)
fun insert(siteSetting: SiteSettings): Long
@Update(onConflict = FAIL)
fun update(siteSetting: SiteSettings): Int
@Delete
fun delete(siteSetting: SiteSettings): Int
}

View file

@ -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<ValidationResult>
@Query("SELECT * FROM validation_results WHERE siteId = :siteId LIMIT 1")
fun forSite(siteId: Long): List<ValidationResult>
@Insert(onConflict = FAIL)
fun insert(siteSetting: ValidationResult): Long
@Update(onConflict = FAIL)
fun update(siteSetting: ValidationResult): Int
@Delete
fun delete(siteSetting: ValidationResult): Int
}

View file

@ -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
)
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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<ServerModel>
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<ServerModel> {
fun get(id: Int? = null): List<ServerModel> {
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<ServerModel> {
val results = mutableListOf<ServerModel>()
@ -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")
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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',
]

View file

@ -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'

View file

@ -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
)
}

View file

@ -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);
}

View file

@ -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,

View file

@ -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
}
}

View file

@ -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

View file

@ -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<ServerModelStore>()
private val manager = RealCheckStatusManager(
private val manager = RealValidationManager(
jobScheduler,
okHttpClient,
stringProvider,

View file

@ -14,7 +14,6 @@ android {
}
dependencies {
implementation project(':data')
implementation project(':utilities')
api 'androidx.appcompat:appcompat:' + versions.androidx

View file

@ -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()
}

View file

@ -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);
}

View file

@ -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

View file

@ -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);
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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()
}
}

View file

@ -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)