Add data model and UI for retry policy, part of #30

This commit is contained in:
Aidan Follestad 2019-01-07 14:55:03 -08:00
parent 09352724ee
commit da7623db79
29 changed files with 538 additions and 54 deletions

View file

@ -37,7 +37,7 @@ class StatusUpdateIntentReceiver(
private var callback: SiteCallback?
) : LifecycleObserver {
private val intentReceiver = object : BroadcastReceiver() {
internal val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent

View file

@ -22,6 +22,7 @@ import android.content.Context.JOB_SCHEDULER_SERVICE
import android.content.Context.NOTIFICATION_SERVICE
import androidx.room.Room.databaseBuilder
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.Database1to2Migration
import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS
import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.utilities.ext.systemService
@ -35,7 +36,11 @@ val mainModule = module {
single(name = MAIN_ACTIVITY_CLASS) { mainActivityCls }
single { databaseBuilder(get(), AppDatabase::class.java, "NockNock.db").build() }
single {
databaseBuilder(get(), AppDatabase::class.java, "NockNock.db")
.addMigrations(Database1to2Migration())
.build()
}
single {
OkHttpClient.Builder()

View file

@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import org.jetbrains.annotations.TestOnly
/** @author Aidan Follestad (@afollestad) */
abstract class ScopedViewModel(mainDispatcher: CoroutineDispatcher) : ViewModel() {
@ -31,5 +32,5 @@ abstract class ScopedViewModel(mainDispatcher: CoroutineDispatcher) : ViewModel(
job.cancel()
}
//@TestOnly open fun destroy() = job.cancel()
@TestOnly open fun destroy() = job.cancel()
}

View file

@ -33,6 +33,7 @@ import kotlinx.android.synthetic.main.activity_addsite.loadingProgress
import kotlinx.android.synthetic.main.activity_addsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
@ -108,6 +109,12 @@ class AddSiteActivity : DarkModeSwitchActivity() {
errorData = viewModel.onCheckIntervalError()
)
// Retry Policy
retryPolicyLayout.attach(
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes
)
// Done button
doneBtn.setOnClickListener {
viewModel.commit {

View file

@ -25,6 +25,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.ValidationMode
@ -59,6 +60,8 @@ class AddSiteViewModel(
val validationScript = MutableLiveData<String>()
val checkIntervalValue = MutableLiveData<Int>()
val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>()
@OnLifecycleEvent(ON_START)
fun setDefaults() {
@ -66,6 +69,8 @@ class AddSiteViewModel(
validationMode.value = STATUS_CODE
checkIntervalValue.value = 0
checkIntervalUnit.value = MINUTE
retryPolicyMinutes.value = 0
retryPolicyMinutes.value = 0
}
// Private properties
@ -223,12 +228,22 @@ class AddSiteViewModel(
networkTimeout = timeout.value!!,
disabled = false
)
val retryPolicyTimes = retryPolicyTimes.value ?: 0
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
RetryPolicy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
} else {
null
}
return Site(
id = 0,
name = name.value!!.trim(),
url = url.value!!.trim(),
settings = newSettings,
lastResult = null
lastResult = null,
retryPolicy = retryPolicy
)
}
}

View file

@ -35,7 +35,7 @@ import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.utilities.ui.toast
import com.afollestad.nocknock.viewUrl
import com.afollestad.nocknock.viewUrlWithApp
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list
import kotlinx.android.synthetic.main.activity_main.loadingProgress
@ -73,10 +73,9 @@ class MainActivity : DarkModeSwitchActivity() {
}
viewModel.onSites()
.observe(this, Observer {
siteAdapter.set(it)
emptyText.showOrHide(it.isEmpty())
})
.observe(this, Observer { siteAdapter.set(it) })
viewModel.onEmptyTextVisibility()
.toViewVisibility(this, emptyText)
loadingProgress.observe(this, viewModel.onIsLoading())
processIntent(intent)

View file

@ -43,11 +43,14 @@ class MainViewModel(
private val sites = MutableLiveData<List<Site>>()
private val isLoading = MutableLiveData<Boolean>()
private val emptyTextVisibility = MutableLiveData<Boolean>()
@CheckResult fun onSites(): LiveData<List<Site>> = sites
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onEmptyTextVisibility(): LiveData<Boolean> = emptyTextVisibility
@OnLifecycleEvent(ON_RESUME)
fun onResume() = loadSites()
@ -76,15 +79,18 @@ class MainViewModel(
scope.launch {
isLoading.value = true
withContext(ioDispatcher) { database.deleteSite(model) }
val currentSites = sites.value ?: return@launch
val index = currentSites.indexOfFirst { it.id == model.id }
isLoading.value = false
if (index == -1) return@launch
sites.value = currentSites.toMutableList()
val newSitesList = currentSites.toMutableList()
.apply {
removeAt(index)
}
sites.value = newSitesList
emptyTextVisibility.value = newSitesList.isEmpty()
}
}
@ -92,6 +98,7 @@ class MainViewModel(
scope.launch {
notificationManager.cancelStatusNotifications()
sites.value = listOf()
emptyTextVisibility.value = false
isLoading.value = true
val result = withContext(ioDispatcher) {
@ -101,6 +108,7 @@ class MainViewModel(
sites.value = result
ensureCheckJobs()
isLoading.value = false
emptyTextVisibility.value = result.isEmpty()
}
}

View file

@ -42,6 +42,7 @@ import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress
import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_viewsite.scrollView
import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult
@ -137,6 +138,12 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
errorData = viewModel.onCheckIntervalError()
)
// Retry Policy
retryPolicyLayout.attach(
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes
)
// Last/next check
viewModel.onLastCheckResultText()
.toViewText(this, textLastCheckResult)

View file

@ -23,6 +23,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.RetryPolicy
import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status
@ -70,6 +71,8 @@ class ViewSiteViewModel(
val validationScript = MutableLiveData<String>()
val checkIntervalValue = MutableLiveData<Int>()
val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>()
internal val disabled = MutableLiveData<Boolean>()
internal val lastResult = MutableLiveData<ValidationResult?>()
@ -309,10 +312,27 @@ class ViewSiteViewModel(
networkTimeout = timeout.value!!,
disabled = false
)
val retryPolicyTimes = retryPolicyTimes.value ?: 0
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
if (site.retryPolicy != null) {
// Have existing policy, update it
site.retryPolicy!!.copy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
} else {
// Create new policy
RetryPolicy(count = retryPolicyTimes, minutes = retryPolicyMinutes)
}
} else {
// No policy
null
}
return site.copy(
name = name.value!!.trim(),
url = url.value!!.trim(),
settings = newSettings
settings = newSettings,
retryPolicy = retryPolicy
)
.withStatus(status = WAITING)
}

View file

@ -15,6 +15,7 @@
*/
package com.afollestad.nocknock.ui.viewsite
import com.afollestad.nocknock.data.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
@ -26,11 +27,8 @@ import com.afollestad.nocknock.utilities.ext.WEEK
import kotlin.math.ceil
fun ViewSiteViewModel.setModel(site: Site) {
requireNotNull(site.settings) {
"Settings must be populated!"
}
val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!")
this.site = site
val settings = site.settings!!
status.value = site.lastResult?.status ?: WAITING
name.value = site.name
@ -54,6 +52,7 @@ fun ViewSiteViewModel.setModel(site: Site) {
}
setCheckInterval(settings.validationIntervalMs)
setRetryPolicy(site.retryPolicy)
this.disabled.value = settings.disabled
this.lastResult.value = site.lastResult
@ -88,6 +87,12 @@ private fun ViewSiteViewModel.setCheckInterval(interval: Long) {
}
}
private fun ViewSiteViewModel.setRetryPolicy(policy: RetryPolicy?) {
if (policy == null) return
retryPolicyTimes.value = policy.count
retryPolicyMinutes.value = policy.minutes
}
private fun getIntervalFromUnit(
millis: Long,
unit: Long

View file

@ -102,6 +102,7 @@
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:layout_marginTop="@dimen/content_inset_quarter"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
@ -109,11 +110,18 @@
style="@style/NockText.Body"
/>
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
android:id="@+id/retryPolicyLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_more"
/>
<TextView
android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_more"
android:text="@string/response_validation_mode"
style="@style/NockText.SectionHeader"
/>

View file

@ -125,11 +125,19 @@
android:layout_marginStart="-4dp"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:layout_marginTop="@dimen/content_inset_quarter"
android:maxLength="8"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
android:id="@+id/retryPolicyLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
@ -234,7 +242,7 @@
android:id="@+id/disableChecksButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half_quarter"
android:layout_marginTop="@dimen/content_inset_quarter"
android:text="@string/disable_automatic_checks"
style="@style/PrimaryDarkButton"
/>

View file

@ -7,7 +7,7 @@
<string name="about">About</string>
<string name="about_body"><![CDATA[
<b>Nock Nock</b>, a simple app designed by <b>Aidan Follestad</b>.<br/>
<a href=\'https://aidanfollestad.com\'>Website</a>&nbsp;&nbsp;
<a href=\'https://af.codes\'>Website</a>&nbsp;&nbsp;
<a href=\'https://twitter.com/afollestad\'>Twitter</a>&nbsp;&nbsp;
<a href=\'https://github.com/afollestad\'>GitHub</a>&nbsp;&nbsp;
<a href=\'https://www.linkedin.com/in/afollestad\'>LinkedIn</a>

View file

@ -19,6 +19,8 @@ import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.RetryPolicy
import com.afollestad.nocknock.data.RetryPolicyDao
import com.afollestad.nocknock.data.SiteDao
import com.afollestad.nocknock.data.SiteSettingsDao
import com.afollestad.nocknock.data.ValidationResultsDao
@ -67,12 +69,23 @@ fun fakeResultModel(
timestampMs = currentTimeMillis()
)
fun fakeRetryPolicy(
id: Long,
count: Int = 3,
minutes: Int = 6
) = RetryPolicy(
siteId = id,
count = count,
minutes = minutes
)
fun fakeModel(id: Long) = Site(
id = id,
name = "Test",
url = "https://test.com",
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id)
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id)
)
val MOCK_MODEL_1 = fakeModel(1)
@ -128,11 +141,26 @@ fun mockDatabase(): AppDatabase {
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val retryDao = mock<RetryPolicyDao> {
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.retryPolicy!!)
2L -> listOf(MOCK_MODEL_2.retryPolicy!!)
3L -> listOf(MOCK_MODEL_3.retryPolicy!!)
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
on { retryPolicyDao() } doReturn retryDao
}
}

View file

@ -56,6 +56,8 @@ class MainViewModelTest {
@Test fun onResume() = runBlocking {
val isLoading = viewModel.onIsLoading()
.test()
val emptyTextVisibility = viewModel.onEmptyTextVisibility()
.test()
val sites = viewModel.onSites()
.test()
@ -69,6 +71,7 @@ class MainViewModelTest {
ALL_MOCK_MODELS
)
isLoading.assertValues(true, false)
emptyTextVisibility.assertValues(false, false)
}
@Test fun postSiteUpdate_notFound() {
@ -138,6 +141,8 @@ class MainViewModelTest {
@Test fun removeSite() {
val sites = viewModel.onSites()
.test()
val emptyTextVisibility = viewModel.onEmptyTextVisibility()
.test()
val isLoading = viewModel.onIsLoading()
.test()
@ -156,6 +161,7 @@ class MainViewModelTest {
sites.assertValues(modelsWithout1)
isLoading.assertValues(true, false)
emptyTextVisibility.assertValues(false, false, false)
verify(validationManager).cancelCheck(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)

View file

@ -45,6 +45,7 @@ class AppDatabaseTest() {
private lateinit var sitesDao: SiteDao
private lateinit var settingsDao: SiteSettingsDao
private lateinit var resultsDao: ValidationResultsDao
private lateinit var retryDao: RetryPolicyDao
@Before fun setup() {
val context = getApplicationContext<Context>()
@ -52,6 +53,7 @@ class AppDatabaseTest() {
sitesDao = db.siteDao()
settingsDao = db.siteSettingsDao()
resultsDao = db.validationResultsDao()
retryDao = db.retryPolicyDao()
}
@After
@ -67,7 +69,8 @@ class AppDatabaseTest() {
name = "Test 1",
url = "https://test1.com",
settings = null,
lastResult = null
lastResult = null,
retryPolicy = null
)
val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0)
@ -76,7 +79,8 @@ class AppDatabaseTest() {
name = "Test 2",
url = "https://test2.com",
settings = null,
lastResult = null
lastResult = null,
retryPolicy = null
)
val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1)
@ -92,7 +96,8 @@ class AppDatabaseTest() {
name = "Test",
url = "https://test.com",
settings = null,
lastResult = null
lastResult = null,
retryPolicy = null
)
val newId = sitesDao.insert(model)
assertThat(newId).isGreaterThan(0)
@ -106,7 +111,8 @@ class AppDatabaseTest() {
name = "Test 1",
url = "https://test1.com",
settings = null,
lastResult = null
lastResult = null,
retryPolicy = null
)
val newId = sitesDao.insert(initialModel)
assertThat(newId).isGreaterThan(0)
@ -129,7 +135,8 @@ class AppDatabaseTest() {
name = "Test 1",
url = "https://test1.com",
settings = null,
lastResult = null
lastResult = null,
retryPolicy = null
)
val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0)
@ -138,7 +145,8 @@ class AppDatabaseTest() {
name = "Test 2",
url = "https://test2.com",
settings = null,
lastResult = null
lastResult = null,
retryPolicy = null
)
val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1)
@ -272,6 +280,59 @@ class AppDatabaseTest() {
assertThat(resultsDao.forSite(1)).isEmpty()
}
// RetryPolicyDao
@Test fun retryPolicy_insert_and_forSite() {
val model = RetryPolicy(
siteId = 1,
count = 3,
minutes = 6
)
val newId = retryDao.insert(model)
assertThat(newId).isEqualTo(1)
val finalModel = resultsDao.forSite(newId)
.single()
assertThat(finalModel).isEqualTo(model.copy(siteId = newId))
}
@Test fun retryPolicy_update() {
retryDao.insert(
RetryPolicy(
siteId = 1,
count = 3,
minutes = 6
)
)
val insertedModel = retryDao.forSite(1)
.single()
val updatedModel = insertedModel.copy(
count = 4,
minutes = 8
)
assertThat(retryDao.update(updatedModel)).isEqualTo(1)
val finalModel = retryDao.forSite(1)
.single()
assertThat(finalModel).isEqualTo(updatedModel)
}
@Test fun retryPolicy_delete() {
retryDao.insert(
RetryPolicy(
siteId = 1,
count = 3,
minutes = 6
)
)
val insertedModel = retryDao.forSite(1)
.single()
retryDao.delete(insertedModel)
assertThat(retryDao.forSite(1)).isEmpty()
}
// Extension Methods
@Test fun extension_put_and_allSites() {
@ -314,11 +375,16 @@ class AppDatabaseTest() {
status = ERROR,
reason = "Oh no"
)
val updatedRetryPolicy = modelToUpdate.retryPolicy!!.copy(
count = 4,
minutes = 8
)
val updatedModel = modelToUpdate.copy(
name = "Oijrfouhef",
url = "https://iojfdfsdk.io",
settings = updatedSettings,
lastResult = updatedValidationResult
lastResult = updatedValidationResult,
retryPolicy = updatedRetryPolicy
)
db.updateSite(updatedModel)
@ -326,6 +392,7 @@ class AppDatabaseTest() {
val finalSite = db.getSite(modelToUpdate.id)!!
assertThat(finalSite.settings).isEqualTo(updatedSettings)
assertThat(finalSite.lastResult).isEqualTo(updatedValidationResult)
assertThat(finalSite.retryPolicy).isEqualTo(updatedRetryPolicy)
assertThat(finalSite).isEqualTo(updatedModel)
}
@ -346,5 +413,10 @@ class AppDatabaseTest() {
assertThat(remainingResults.size).isEqualTo(2)
assertThat(remainingResults[0]).isEqualTo(allSites[0].lastResult!!)
assertThat(remainingResults[1]).isEqualTo(allSites[2].lastResult!!)
val remainingRetryPolicies = retryDao.all()
assertThat(remainingRetryPolicies.size).isEqualTo(2)
assertThat(remainingRetryPolicies[0]).isEqualTo(allSites[0].retryPolicy!!)
assertThat(remainingRetryPolicies[1]).isEqualTo(allSites[2].retryPolicy!!)
}
}

View file

@ -47,12 +47,23 @@ fun fakeResultModel(
timestampMs = currentTimeMillis()
)
fun fakeRetryPolicy(
id: Long,
count: Int = 3,
minutes: Int = 6
) = RetryPolicy(
siteId = id,
count = count,
minutes = minutes
)
fun fakeModel(id: Long) = Site(
id = id,
name = "Test",
url = "https://test.com",
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id)
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id)
)
val MOCK_MODEL_1 = fakeModel(1)

View file

@ -26,11 +26,12 @@ import com.afollestad.nocknock.data.model.ValidationResult
/** @author Aidan Follestad (@afollestad) */
@Database(
entities = [
RetryPolicy::class,
ValidationResult::class,
SiteSettings::class,
Site::class
],
version = 1,
version = 2,
exportSchema = false
)
@TypeConverters(Converters::class)
@ -41,6 +42,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun siteSettingsDao(): SiteSettingsDao
abstract fun validationResultsDao(): ValidationResultsDao
abstract fun retryPolicyDao(): RetryPolicyDao
}
/**
@ -55,9 +58,12 @@ fun AppDatabase.allSites(): List<Site> {
.single()
val lastResult = validationResultsDao().forSite(it.id)
.singleOrNull()
val retryPolicy = retryPolicyDao().forSite(it.id)
.singleOrNull()
return@map it.copy(
settings = settings,
lastResult = lastResult
lastResult = lastResult,
retryPolicy = retryPolicy
)
}
}
@ -74,9 +80,12 @@ fun AppDatabase.getSite(id: Long): Site? {
.single()
val lastResult = validationResultsDao().forSite(id)
.singleOrNull()
val retryPolicy = retryPolicyDao().forSite(id)
.singleOrNull()
return result.copy(
settings = settings,
lastResult = lastResult
lastResult = lastResult,
retryPolicy = retryPolicy
)
}
@ -86,14 +95,14 @@ fun AppDatabase.getSite(id: Long): Site? {
* @author Aidan Follestad (@afollestad)
*/
fun AppDatabase.putSite(site: Site): Site {
requireNotNull(site.settings) { "Settings must be populated." }
val settings = site.settings ?: throw IllegalArgumentException("Settings cannot be null.")
val newId = siteDao().insert(site)
val settingsWithSiteId =
site.settings!!.copy(
siteId = newId
)
val settingsWithSiteId = settings.copy(siteId = newId)
siteSettingsDao().insert(settingsWithSiteId)
site.lastResult?.let { validationResultsDao().insert(it) }
site.retryPolicy?.let { retryPolicyDao().insert(it) }
return site.copy(
id = newId,
settings = settingsWithSiteId
@ -107,26 +116,37 @@ fun AppDatabase.putSite(site: Site): Site {
*/
fun AppDatabase.updateSite(site: Site) {
siteDao().update(site)
if (site.settings != null) {
val settings = site.settings?.copy(siteId = site.id)
if (settings != null) {
val existing = siteSettingsDao().forSite(site.id)
.singleOrNull()
if (existing != null) {
siteSettingsDao().update(site.settings!!)
siteSettingsDao().update(settings)
} else {
siteSettingsDao().insert(
site.settings!!.copy(
siteId = site.id
)
)
siteSettingsDao().insert(settings)
}
}
if (site.lastResult != null) {
val lastResult = site.lastResult?.copy(siteId = site.id)
if (lastResult != null) {
val existing = validationResultsDao().forSite(site.id)
.singleOrNull()
if (existing != null) {
validationResultsDao().update(site.lastResult!!)
validationResultsDao().update(lastResult)
} else {
validationResultsDao().insert(site.lastResult!!)
validationResultsDao().insert(lastResult)
}
}
val retryPolicy = site.retryPolicy?.copy(siteId = site.id)
if (retryPolicy != null) {
val existing = retryPolicyDao().forSite(site.id)
.singleOrNull()
if (existing != null) {
retryPolicyDao().update(retryPolicy)
} else {
retryPolicyDao().insert(retryPolicy)
}
}
}
@ -137,11 +157,8 @@ fun AppDatabase.updateSite(site: Site) {
* @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!!)
}
site.settings?.let { siteSettingsDao().delete(it) }
site.lastResult?.let { validationResultsDao().delete(it) }
site.retryPolicy?.let { retryPolicyDao().delete(it) }
siteDao().delete(site)
}

View file

@ -0,0 +1,33 @@
/**
* 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.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Migrates the database from version 1 to 2.
*
* @author Aidan Follestad (@afollestad)
*/
class Database1to2Migration : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE `retry_policies` (siteId INTEGER PRIMARY KEY NOT NULL, count INTEGER NOT NULL, minutes INTEGER NOT NULL)"
)
}
}

View file

@ -0,0 +1,55 @@
/**
* 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
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.afollestad.nocknock.utilities.ext.MINUTE
import java.io.Serializable
/**
* Represents a site's retry policy, or how many times we
* retry in a certain timespan before considering a site to
* have a problem.
*
* @author Aidan Follestad (@afollestad)
*/
@Entity(tableName = "retry_policies")
data class RetryPolicy(
/** The [Site] these settings belong to. */
@PrimaryKey(autoGenerate = false) var siteId: Long = 0,
/** How many times we want to retry. */
var count: Int = 0,
/**
* In what amount of time (in minutes) we want
* to perform those retries.
*/
var minutes: Int = 0
) : Serializable {
constructor() : this(0, 0, 0)
// Say we are trying 6 times in 3 minutes, that means times per minute = 2.
// Twice per minute means every 30 seconds.
// 30 seconds = 30 * 1000 or 30,000 milliseconds.
// 60,000 / 2 = 30,000.
fun interval(): Long {
val timesPerMinute = count.toFloat() / minutes.toFloat()
return MINUTE / timesPerMinute.toInt()
}
}

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.
*/
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
/** @author Aidan Follestad (@afollestad) */
@Dao
interface RetryPolicyDao {
@Query("SELECT * FROM retry_policies ORDER BY siteId ASC")
fun all(): List<RetryPolicy>
@Query("SELECT * FROM retry_policies WHERE siteId = :siteId LIMIT 1")
fun forSite(siteId: Long): List<RetryPolicy>
@Insert(onConflict = FAIL)
fun insert(policy: RetryPolicy): Long
@Update(onConflict = FAIL)
fun update(policy: RetryPolicy): Int
@Delete
fun delete(policy: RetryPolicy): Int
}

View file

@ -18,6 +18,7 @@ package com.afollestad.nocknock.data.model
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.afollestad.nocknock.data.RetryPolicy
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.utilities.ext.timeString
import com.afollestad.nocknock.utilities.providers.CanNotifyModel
@ -36,10 +37,12 @@ data class Site(
/** Settings for the site. */
@Ignore var settings: SiteSettings?,
/** The last validation attempt result for the site, if any. */
@Ignore var lastResult: ValidationResult?
@Ignore var lastResult: ValidationResult?,
/** The site's retry policy, if any. */
@Ignore var retryPolicy: RetryPolicy?
) : CanNotifyModel {
constructor() : this(0, "", "", null, null)
constructor() : this(0, "", "", null, null, null)
override fun notifyId(): Int = id.toInt()

View file

@ -0,0 +1,46 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.viewcomponents.retrypolicy
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
import kotlinx.android.synthetic.main.retry_policy_layout.view.minutes
import kotlinx.android.synthetic.main.retry_policy_layout.view.times
/** @author Aidan Follestad (@afollestad) */
class RetryPolicyLayout(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
init {
orientation = VERTICAL
inflate(context, R.layout.retry_policy_layout, this)
}
fun attach(
timesData: MutableLiveData<Int>,
minutesData: MutableLiveData<Int>
) {
times.attachLiveData(lifecycleOwner(), timesData)
minutes.attachLiveData(lifecycleOwner(), minutesData)
}
}

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:parentTag="android.widget.LinearLayout"
>
<TextView
android:id="@+id/label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/retry_policy"
style="@style/NockText.SectionHeader"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_quarter"
android:gravity="center"
android:orientation="horizontal"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/content_inset"
android:text="@string/retry_policy_retry"
style="@style/NockText.Body"
/>
<EditText
android:id="@+id/times"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="0"
android:inputType="number"
android:maxLength="4"
android:nextFocusDown="@+id/minutes"
android:nextFocusRight="@+id/minutes"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/content_inset"
android:layout_marginStart="@dimen/content_inset"
android:text="@string/retry_policy_times_in"
style="@style/NockText.Body"
/>
<EditText
android:id="@+id/minutes"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="0"
android:inputType="number"
android:maxLength="4"
android:nextFocusLeft="@+id/times"
android:nextFocusUp="@+id/times"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/content_inset"
android:text="@string/retry_policy_minutes"
style="@style/NockText.Body"
/>
</LinearLayout>
</merge>

View file

@ -5,7 +5,7 @@
<dimen name="default_elevation">4dp</dimen>
<dimen name="content_inset_half_quarter">4dp</dimen>
<dimen name="content_inset_quarter">4dp</dimen>
<dimen name="content_inset_half">8dp</dimen>
<dimen name="content_inset_less">12dp</dimen>
<dimen name="content_inset">16dp</dimen>

View file

@ -7,4 +7,9 @@
<string name="check_interval">Check Interval</string>
<string name="retry_policy">Retry Policy</string>
<string name="retry_policy_retry">Retry</string>
<string name="retry_policy_times_in">times in</string>
<string name="retry_policy_minutes">Minutes</string>
</resources>