Improved a lot of the UI, cleaned up some stuff. Add the ability to add headers to sites, resolves #39.

This commit is contained in:
Aidan Follestad 2019-01-08 16:14:08 -08:00
parent 26ab76b363
commit 646bc25232
43 changed files with 846 additions and 278 deletions

View file

@ -24,6 +24,7 @@ import androidx.room.Room.databaseBuilder
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.Database1to2Migration
import com.afollestad.nocknock.data.Database2to3Migration
import com.afollestad.nocknock.data.Database3to4Migration
import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS
import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.utilities.ext.systemService
@ -41,7 +42,8 @@ val mainModule = module {
databaseBuilder(get(), AppDatabase::class.java, "NockNock.db")
.addMigrations(
Database1to2Migration(),
Database2to3Migration()
Database2to3Migration(),
Database3to4Migration()
)
.build()
}

View file

@ -21,12 +21,14 @@ import android.widget.ArrayAdapter
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
import kotlinx.android.synthetic.main.activity_addsite.headersLayout
import kotlinx.android.synthetic.main.activity_addsite.inputName
import kotlinx.android.synthetic.main.activity_addsite.inputTags
import kotlinx.android.synthetic.main.activity_addsite.inputUrl
@ -36,10 +38,12 @@ 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.scrollView
import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
import kotlinx.android.synthetic.main.include_app_bar.toolbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
/** @author Aidan Follestad (@afollestad) */
@ -118,11 +122,24 @@ class AddSiteActivity : DarkModeSwitchActivity() {
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes
)
// Headers
headersLayout.attach(viewModel.headers)
}
private fun setupUi() {
toolbarTitle.setText(R.string.add_site)
toolbar.run {
inflateMenu(R.menu.menu_addsite)
setOnMenuItemClickListener {
if (it.itemId == R.id.commit) {
viewModel.commit {
setResult(RESULT_OK)
finish()
}
}
true
}
setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() }
}
@ -135,11 +152,11 @@ class AddSiteActivity : DarkModeSwitchActivity() {
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
// Done button
doneBtn.setOnClickListener {
viewModel.commit {
setResult(RESULT_OK)
finish()
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
}

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.model.Header
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
@ -35,7 +36,7 @@ import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.data.putSite
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.livedata.map
@ -49,7 +50,7 @@ import java.lang.System.currentTimeMillis
/** @author Aidan Follestad (@afollestad) */
class AddSiteViewModel(
private val database: AppDatabase,
private val validationManager: ValidationManager,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
@ -66,6 +67,7 @@ class AddSiteViewModel(
val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>()
@OnLifecycleEvent(ON_START)
fun setDefaults() {
@ -76,6 +78,7 @@ class AddSiteViewModel(
retryPolicyMinutes.value = 0
retryPolicyMinutes.value = 0
tags.value = ""
headers.value = emptyList()
}
// Private properties
@ -134,7 +137,7 @@ class AddSiteViewModel(
val storedModel = withContext(ioDispatcher) {
database.putSite(newModel)
}
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = storedModel,
rightNow = true,
cancelPrevious = true
@ -260,7 +263,8 @@ class AddSiteViewModel(
tags = cleanedTags,
settings = newSettings,
lastResult = newLastResult,
retryPolicy = newRetryPolicy
retryPolicy = newRetryPolicy,
headers = headers.value ?: emptyList()
)
}
}

View file

@ -25,7 +25,7 @@ import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.allSites
import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.ScopedViewModel
import kotlinx.coroutines.CoroutineDispatcher
@ -36,7 +36,7 @@ import kotlinx.coroutines.withContext
class MainViewModel(
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val validationManager: ValidationManager,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
@ -73,7 +73,7 @@ class MainViewModel(
}
fun refreshSite(model: Site) {
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = model,
rightNow = true,
cancelPrevious = true
@ -81,7 +81,7 @@ class MainViewModel(
}
fun removeSite(model: Site) {
validationManager.cancelCheck(model)
validationManager.cancelScheduledValidation(model)
notificationManager.cancelStatusNotification(model)
scope.launch {
@ -134,7 +134,7 @@ class MainViewModel(
private suspend fun ensureCheckJobs() {
withContext(ioDispatcher) {
validationManager.ensureScheduledChecks()
validationManager.ensureScheduledValidations()
}
}

View file

@ -33,8 +33,7 @@ import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
import kotlinx.android.synthetic.main.activity_viewsite.headersLayout
import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
import kotlinx.android.synthetic.main.activity_viewsite.inputName
import kotlinx.android.synthetic.main.activity_viewsite.inputTags
@ -50,6 +49,7 @@ import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult
import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck
import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
import kotlinx.android.synthetic.main.include_app_bar.toolbar
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
@ -148,6 +148,9 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
minutesData = viewModel.retryPolicyMinutes
)
// Headers
headersLayout.attach(viewModel.headers)
// Last/next check
viewModel.onLastCheckResultText()
.toViewText(this, textLastCheckResult)
@ -156,25 +159,31 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
}
private fun setupUi() {
toolbarTitle.setText(R.string.view_site)
toolbarTitle.text = ""
toolbar.run {
setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() }
inflateMenu(R.menu.menu_viewsite)
menu.findItem(R.id.refresh)
.setActionView(R.layout.menu_item_refresh_icon)
.apply {
actionView.setOnClickListener { viewModel.checkNow() }
}
setOnMenuItemClickListener {
maybeRemoveSite()
when (it.itemId) {
R.id.commit -> viewModel.commit { finish() }
R.id.remove -> maybeRemoveSite()
R.id.disableChecks -> maybeDisableChecks()
}
true
}
}
scrollView.onScroll {
toolbar.elevation = if (it > toolbar.height / 4) {
toolbar.dimenFloat(R.dimen.default_elevation)
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
@ -190,15 +199,17 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
// Disabled button
viewModel.onDisableChecksVisibility()
.toViewVisibility(this, disableChecksButton)
disableChecksButton.setOnClickListener { maybeDisableChecks() }
.observe(this, Observer {
toolbar.menu.findItem(R.id.disableChecks)
.isVisible = it
})
// Done button
viewModel.onDoneButtonText()
.toViewText(this, doneBtn)
doneBtn.setOnClickListener {
viewModel.commit { finish() }
}
.observe(this, Observer {
toolbar.menu.findItem(R.id.commit)
.setTitle(it)
})
}
override fun onNewIntent(intent: Intent?) {

View file

@ -25,6 +25,7 @@ import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.deleteSite
import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.Status.WAITING
@ -35,7 +36,7 @@ import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.data.model.textRes
import com.afollestad.nocknock.data.updateSite
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.ext.formatDate
@ -54,7 +55,7 @@ class ViewSiteViewModel(
private val stringProvider: StringProvider,
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val validationManager: ValidationManager,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
@ -74,6 +75,7 @@ class ViewSiteViewModel(
val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>()
internal val disabled = MutableLiveData<Boolean>()
internal val lastResult = MutableLiveData<ValidationResult?>()
@ -169,7 +171,7 @@ class ViewSiteViewModel(
withContext(ioDispatcher) {
database.updateSite(updatedModel)
}
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = updatedModel,
rightNow = true,
cancelPrevious = true
@ -185,7 +187,7 @@ class ViewSiteViewModel(
status = WAITING
)
setModel(checkModel)
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = checkModel,
rightNow = true,
cancelPrevious = true
@ -193,7 +195,7 @@ class ViewSiteViewModel(
}
fun removeSite(done: () -> Unit) {
validationManager.cancelCheck(site)
validationManager.cancelScheduledValidation(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
@ -207,7 +209,7 @@ class ViewSiteViewModel(
}
fun disableSite() {
validationManager.cancelCheck(site)
validationManager.cancelScheduledValidation(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
@ -339,7 +341,8 @@ class ViewSiteViewModel(
tags = cleanedTags,
url = url.value!!.trim(),
settings = newSettings,
retryPolicy = retryPolicy
retryPolicy = retryPolicy,
headers = headers.value ?: emptyList()
)
.withStatus(status = WAITING)
}

View file

@ -54,6 +54,7 @@ fun ViewSiteViewModel.setModel(site: Site) {
setCheckInterval(settings.validationIntervalMs)
setRetryPolicy(site.retryPolicy)
headers.value = site.headers
this.disabled.value = settings.disabled
this.lastResult.value = site.lastResult

View file

@ -0,0 +1,10 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp">
<path
android:fillColor="?iconColor"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View file

@ -16,6 +16,7 @@
<include layout="@layout/include_app_bar"/>
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
@ -24,89 +25,62 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingBottom="@dimen/content_inset_double"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/nameTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:nextFocusDown="@+id/urlTiLayout"
>
<TextView
android:layout_marginTop="0dp"
android:text="@string/site_name"
style="@style/InputForm.Header"
/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
style="@style/NockText.Body"
/>
<EditText
android:id="@+id/inputName"
android:hint="@string/site_name_hint"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:text="@string/site_url"
style="@style/InputForm.Header"
/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/urlTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_half"
android:nextFocusDown="@+id/tagsTiLayout"
>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_url"
android:inputType="textUri"
android:nextFocusDown="@+id/inputTags"
style="@style/NockText.Body"
/>
</com.google.android.material.textfield.TextInputLayout>
<EditText
android:id="@+id/inputUrl"
android:hint="@string/site_url_hint"
android:inputType="textUri"
android:nextFocusDown="@+id/inputTags"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<TextView
android:id="@+id/textUrlWarning"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:text="@string/warning_http_url"
android:visibility="gone"
style="@style/NockText.Footnote"
style="@style/InputForm.FieldNote"
/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tagsTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_half"
android:nextFocusDown="@+id/urlTiLayout"
>
<TextView
android:text="@string/site_tags"
style="@style/InputForm.Header"
/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputTags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits=",.?-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "
android:hint="@string/site_tags_hint"
android:imeOptions="actionNext"
android:inputType="text|textCapWords"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
style="@style/NockText.Body"
/>
</com.google.android.material.textfield.TextInputLayout>
<EditText
android:id="@+id/inputTags"
android:digits=",.?-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "
android:hint="@string/site_tags_hint"
android:inputType="text|textCapWords"
android:nextFocusDown="@+id/inputUrl"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<include layout="@layout/include_divider"/>
@ -118,34 +92,23 @@
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_less"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
style="@style/InputForm.Header"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
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"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<TextView
android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_more"
android:text="@string/response_validation_mode"
style="@style/NockText.SectionHeader"
style="@style/InputForm.Header"
/>
<Spinner
@ -187,12 +150,7 @@
style="@style/NockText.Body.Light"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
android:background="?dividerColor"
/>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
android:id="@+id/retryPolicyLayout"
@ -201,13 +159,13 @@
android:layout_marginTop="@dimen/content_inset_more"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/doneBtn"
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
android:id="@+id/headersLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_double"
android:text="@string/add_site"
style="@style/AccentButton"
android:layout_marginTop="@dimen/content_inset_half"
/>
</LinearLayout>

View file

@ -29,9 +29,23 @@
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
android:paddingTop="@dimen/content_inset_less"
>
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Header"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -55,19 +69,6 @@
android:orientation="vertical"
>
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
<EditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
@ -127,7 +128,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_less"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
@ -217,6 +218,16 @@
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
android:id="@+id/headersLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
/>
<include layout="@layout/include_divider"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -251,24 +262,6 @@
style="@style/NockText.Body"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/doneBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_double"
android:text="@string/save_changes"
style="@style/AccentButton"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/disableChecksButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_quarter"
android:text="@string/disable_automatic_checks"
style="@style/PrimaryDarkButton"
/>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/commit"
android:icon="@drawable/ic_check"
android:title="@string/add_site"
app:showAsAction="ifRoom"/>
</menu>

View file

@ -1,17 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/commit"
android:icon="@drawable/ic_check"
android:title="@string/save_changes"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/refresh"
android:icon="@drawable/ic_action_refresh"
android:title="@string/refresh_status"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/remove"
android:icon="@drawable/ic_action_delete"
android:title="@string/remove_site"
app:showAsAction="ifRoom"/>
<item
android:id="@+id/disableChecks"
android:title="@string/disable_automatic_checks"
/>
</menu>

View file

@ -2,5 +2,6 @@
<dimen name="empty_text_size">28sp</dimen>
<dimen name="list_text_spacing">6dp</dimen>
<dimen name="toolbar_elevation">4dp</dimen>
</resources>

View file

@ -20,9 +20,11 @@
<string name="dismiss">Dismiss</string>
<string name="add_site">Add Site</string>
<string name="site_name">Site Name</string>
<string name="site_name_hint">Site display name</string>
<string name="site_url">Site URL</string>
<string name="site_url_hint">https://yoursite.com</string>
<string name="site_tags">Site Tags</string>
<string name="site_tags_hint">Site Tags (one, two, three)</string>
<string name="site_tags_hint">One,Two,Three</string>
<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>
@ -46,8 +48,8 @@
<string name="disable_automatic_checks">Disable Automatic Validation</string>
<string name="disable_automatic_checks_prompt"><![CDATA[
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.
until you re-enable validation by tapping the checkmark (Save) icon. 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 Auto Validation &amp; Save Changes</string>

View file

@ -16,4 +16,8 @@
<item name="android:fontFamily">@font/lato</item>
</style>
<style name="AccentTextButton" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:fontFamily">@font/lato</item>
</style>
</resources>

View file

@ -6,4 +6,28 @@
<item name="android:textColor">?toolbarTitleColor</item>
</style>
<style name="InputForm"/>
<style name="InputForm.Header" parent="NockText.SectionHeader">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/content_inset_less</item>
</style>
<style name="InputForm.Field" parent="NockText.Body">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/content_inset_quarter</item>
<item name="android:singleLine">true</item>
<item name="android:imeOptions">actionNext</item>
<item name="android:layout_marginStart">-4dp</item>
<item name="android:layout_marginEnd">-4dp</item>
</style>
<style name="InputForm.FieldNote" parent="NockText.Footnote">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/list_text_spacing</item>
</style>
</resources>

View file

@ -24,6 +24,7 @@ import com.afollestad.nocknock.data.RetryPolicyDao
import com.afollestad.nocknock.data.SiteDao
import com.afollestad.nocknock.data.SiteSettingsDao
import com.afollestad.nocknock.data.ValidationResultsDao
import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status
@ -79,6 +80,13 @@ fun fakeRetryPolicy(
minutes = minutes
)
fun fakeHeaders(siteId: Long): List<Header> {
return listOf(
Header(id = siteId + 1, siteId = siteId, key = "Content-Type", value = "text/html"),
Header(id = siteId + 2, siteId = siteId, key = "User-Agent", value = "NockNock")
)
}
fun fakeModel(id: Long) = Site(
id = id,
name = "Test",
@ -86,7 +94,8 @@ fun fakeModel(id: Long) = Site(
tags = "",
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id)
retryPolicy = fakeRetryPolicy(id),
headers = fakeHeaders(id)
)
val MOCK_MODEL_1 = fakeModel(1)

View file

@ -22,7 +22,7 @@ import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.livedata.test
@ -44,7 +44,7 @@ import org.junit.Test
class AddSiteViewModelTest {
private val database = mockDatabase()
private val validationManager = mock<ValidationManager>()
private val validationManager = mock<ValidationExecutor>()
@Rule @JvmField val rule = InstantTaskExecutorRule()
@ -170,7 +170,7 @@ class AddSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertValues(R.string.please_enter_name)
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
@ -202,7 +202,7 @@ class AddSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertValues(R.string.please_enter_url)
onTimeoutError.assertNoValues()
@ -234,7 +234,7 @@ class AddSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertValues(R.string.please_enter_valid_url)
onTimeoutError.assertNoValues()
@ -266,7 +266,7 @@ class AddSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertValues(R.string.please_enter_networkTimeout)
@ -298,7 +298,7 @@ class AddSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
@ -331,7 +331,7 @@ class AddSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
@ -364,7 +364,7 @@ class AddSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
@ -410,7 +410,7 @@ class AddSiteViewModelTest {
lastResult = null
)
verify(validationManager).scheduleCheck(
verify(validationManager).scheduleValidation(
site = model,
rightNow = true,
cancelPrevious = true,

View file

@ -20,7 +20,7 @@ import com.afollestad.nocknock.ALL_MOCK_MODELS
import com.afollestad.nocknock.MOCK_MODEL_1
import com.afollestad.nocknock.MOCK_MODEL_2
import com.afollestad.nocknock.MOCK_MODEL_3
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.livedata.test
@ -39,7 +39,7 @@ class MainViewModelTest {
private val database = mockDatabase()
private val notificationManager = mock<NockNotificationManager>()
private val validationManager = mock<ValidationManager>()
private val validationManager = mock<ValidationExecutor>()
@Rule @JvmField val rule = InstantTaskExecutorRule()
@ -64,7 +64,7 @@ class MainViewModelTest {
viewModel.onResume()
verify(notificationManager).cancelStatusNotifications()
verify(validationManager).ensureScheduledChecks()
verify(validationManager).ensureScheduledValidations()
sites.assertValues(
listOf(),
@ -106,7 +106,7 @@ class MainViewModelTest {
@Test fun refreshSite() {
viewModel.refreshSite(MOCK_MODEL_3)
verify(validationManager).scheduleCheck(
verify(validationManager).scheduleValidation(
site = MOCK_MODEL_3,
rightNow = true,
cancelPrevious = true
@ -132,7 +132,7 @@ class MainViewModelTest {
sites.assertNoValues()
isLoading.assertValues(true, false)
verify(validationManager).cancelCheck(modifiedModel)
verify(validationManager).cancelScheduledValidation(modifiedModel)
verify(notificationManager).cancelStatusNotification(modifiedModel)
verify(database.siteDao()).delete(modifiedModel)
verify(database.siteSettingsDao()).delete(modifiedModel.settings!!)
@ -163,7 +163,7 @@ class MainViewModelTest {
isLoading.assertValues(true, false)
emptyTextVisibility.assertValues(false, false, false)
verify(validationManager).cancelCheck(MOCK_MODEL_1)
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
verify(database.siteDao()).delete(MOCK_MODEL_1)
verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)

View file

@ -28,7 +28,7 @@ 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.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.mockDatabase
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.livedata.test
@ -75,7 +75,7 @@ class ViewSiteViewModelTest {
}
}
private val database = mockDatabase()
private val validationManager = mock<ValidationManager>()
private val validationManager = mock<ValidationExecutor>()
private val notificationManager = mock<NockNotificationManager>()
@Rule @JvmField val rule = InstantTaskExecutorRule()
@ -276,7 +276,7 @@ class ViewSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertValues(R.string.please_enter_name)
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
@ -308,7 +308,7 @@ class ViewSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertValues(R.string.please_enter_url)
onTimeoutError.assertNoValues()
@ -340,7 +340,7 @@ class ViewSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertValues(R.string.please_enter_valid_url)
onTimeoutError.assertNoValues()
@ -372,7 +372,7 @@ class ViewSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertValues(R.string.please_enter_networkTimeout)
@ -404,7 +404,7 @@ class ViewSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
@ -437,7 +437,7 @@ class ViewSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
@ -470,7 +470,7 @@ class ViewSiteViewModelTest {
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleCheck(any(), any(), any(), any())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
@ -534,7 +534,7 @@ class ViewSiteViewModelTest {
assertThat(settingsCaptor.firstValue).isEqualTo(updatedSettings)
assertThat(resultCaptor.firstValue).isEqualTo(updatedResult)
verify(validationManager).scheduleCheck(
verify(validationManager).scheduleValidation(
site = updatedModel,
rightNow = true,
cancelPrevious = true,
@ -562,7 +562,7 @@ class ViewSiteViewModelTest {
)
viewModel.checkNow()
verify(validationManager).scheduleCheck(
verify(validationManager).scheduleValidation(
site = expectedModel,
rightNow = true,
cancelPrevious = true
@ -579,7 +579,7 @@ class ViewSiteViewModelTest {
viewModel.removeSite(onDone)
isLoading.assertValues(true, false)
verify(validationManager).cancelCheck(MOCK_MODEL_1)
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
verify(database.siteDao()).delete(MOCK_MODEL_1)
verify(database.siteSettingsDao()).delete(MOCK_MODEL_1.settings!!)
@ -603,7 +603,7 @@ class ViewSiteViewModelTest {
)
)
verify(validationManager).cancelCheck(MOCK_MODEL_1)
verify(validationManager).cancelScheduledValidation(MOCK_MODEL_1)
verify(notificationManager).cancelStatusNotification(MOCK_MODEL_1)
verify(database.siteDao()).update(expectedSite)
verify(database.siteSettingsDao()).update(expectedSite.settings!!)

View file

@ -21,6 +21,7 @@ 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.Header
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
@ -47,6 +48,7 @@ class AppDatabaseTest() {
private lateinit var settingsDao: SiteSettingsDao
private lateinit var resultsDao: ValidationResultsDao
private lateinit var retryDao: RetryPolicyDao
private lateinit var headerDao: HeaderDao
@Before fun setup() {
val context = getApplicationContext<Context>()
@ -55,6 +57,7 @@ class AppDatabaseTest() {
settingsDao = db.siteSettingsDao()
resultsDao = db.validationResultsDao()
retryDao = db.retryPolicyDao()
headerDao = db.headerDao()
}
@After
@ -70,7 +73,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0)
@ -81,7 +85,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1)
@ -99,7 +104,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId = sitesDao.insert(model)
assertThat(newId).isGreaterThan(0)
@ -115,7 +121,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId = sitesDao.insert(initialModel)
assertThat(newId).isGreaterThan(0)
@ -140,7 +147,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId1 = sitesDao.insert(model1)
assertThat(newId1).isGreaterThan(0)
@ -151,7 +159,8 @@ class AppDatabaseTest() {
tags = "",
settings = null,
lastResult = null,
retryPolicy = null
retryPolicy = null,
headers = emptyList()
)
val newId2 = sitesDao.insert(model2)
assertThat(newId2).isGreaterThan(newId1)
@ -338,6 +347,78 @@ class AppDatabaseTest() {
assertThat(retryDao.forSite(1)).isEmpty()
}
// HeaderDao
@Test fun headers_insert_and_forSite() {
val models = listOf(
Header(
siteId = 1,
key = "Name",
value = "Aidan"
),
Header(
siteId = 1,
key = "Born",
value = "1995"
)
)
val newIds = headerDao.insert(models)
assertThat(newIds.first()).isEqualTo(1)
assertThat(newIds.last()).isEqualTo(2)
val finalModels = headerDao.forSite(1)
assertThat(finalModels.first()).isEqualTo(models.first().copy(id = 1))
assertThat(finalModels.last()).isEqualTo(models.last().copy(id = 2))
}
@Test fun headers_update() {
val models = listOf(
Header(
siteId = 1,
key = "Name",
value = "Aidan"
),
Header(
siteId = 1,
key = "Born",
value = "1995"
)
)
headerDao.insert(models)
val insertedModel = headerDao.forSite(1)
.last()
val updatedModel = insertedModel.copy(
key = "Test",
value = "Hello"
)
assertThat(headerDao.update(updatedModel)).isEqualTo(1)
val finalModels = headerDao.forSite(1)
assertThat(finalModels.first()).isEqualTo(models.first().copy(id = 1))
assertThat(finalModels.last()).isEqualTo(updatedModel)
}
@Test fun headers_delete() {
val models = listOf(
Header(
siteId = 1,
key = "Name",
value = "Aidan"
),
Header(
siteId = 1,
key = "Born",
value = "1995"
)
)
headerDao.insert(models)
val insertedModels = headerDao.forSite(1)
headerDao.delete(insertedModels)
assertThat(headerDao.forSite(1)).isEmpty()
}
// Extension Methods
@Test fun extension_put_and_allSites() {
@ -352,25 +433,6 @@ class AppDatabaseTest() {
assertThat(allSites[2]).isEqualTo(MOCK_MODEL_3)
}
@Test fun extension_put_and_allSites_withTag() {
val model1 = MOCK_MODEL_1.copy(tags = "one,two,three")
val model2 = MOCK_MODEL_2.copy(tags = "four,five,six")
val model3 = MOCK_MODEL_3.copy(tags = "seven,eight,nine")
db.putSite(model1)
db.putSite(model2)
db.putSite(model3)
val allSites1 = db.allSites(forTag = "one")
assertThat(allSites1.single()).isEqualTo(model1)
val allSites2 = db.allSites(forTag = "five")
assertThat(allSites2.single()).isEqualTo(model2)
val allSites3 = db.allSites(forTag = "nine")
assertThat(allSites3.single()).isEqualTo(model3)
}
@Test fun extension_put_getSite() {
db.putSite(MOCK_MODEL_1)
db.putSite(MOCK_MODEL_2)
@ -403,12 +465,23 @@ class AppDatabaseTest() {
count = 4,
minutes = 8
)
val updatedHeaders = listOf(
modelToUpdate.headers.first().copy(
key = "One",
value = "Hello"
),
modelToUpdate.headers.last().copy(
key = "Two",
value = "Hey"
)
)
val updatedModel = modelToUpdate.copy(
name = "Oijrfouhef",
url = "https://iojfdfsdk.io",
settings = updatedSettings,
lastResult = updatedValidationResult,
retryPolicy = updatedRetryPolicy
retryPolicy = updatedRetryPolicy,
headers = updatedHeaders
)
db.updateSite(updatedModel)
@ -417,6 +490,8 @@ class AppDatabaseTest() {
assertThat(finalSite.settings).isEqualTo(updatedSettings)
assertThat(finalSite.lastResult).isEqualTo(updatedValidationResult)
assertThat(finalSite.retryPolicy).isEqualTo(updatedRetryPolicy)
assertThat(finalSite.headers.first()).isEqualTo(updatedHeaders.first())
assertThat(finalSite.headers.last()).isEqualTo(updatedHeaders.last())
assertThat(finalSite).isEqualTo(updatedModel)
}
@ -426,7 +501,7 @@ class AppDatabaseTest() {
db.putSite(MOCK_MODEL_3)
val allSites = db.allSites()
db.deleteSite(MOCK_MODEL_2)
db.deleteSite(allSites[1])
val remainingSettings = settingsDao.all()
assertThat(remainingSettings.size).isEqualTo(2)
@ -442,5 +517,12 @@ class AppDatabaseTest() {
assertThat(remainingRetryPolicies.size).isEqualTo(2)
assertThat(remainingRetryPolicies[0]).isEqualTo(allSites[0].retryPolicy!!)
assertThat(remainingRetryPolicies[1]).isEqualTo(allSites[2].retryPolicy!!)
val remainingHeaders = headerDao.all()
assertThat(remainingHeaders.size).isEqualTo(4)
assertThat(remainingHeaders[0]).isEqualTo(allSites[0].headers.first())
assertThat(remainingHeaders[1]).isEqualTo(allSites[0].headers.last())
assertThat(remainingHeaders[2]).isEqualTo(allSites[2].headers.first())
assertThat(remainingHeaders[3]).isEqualTo(allSites[2].headers.last())
}
}

View file

@ -15,6 +15,7 @@
*/
package com.afollestad.nocknock.data
import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
@ -58,6 +59,11 @@ fun fakeRetryPolicy(
minutes = minutes
)
fun fakeHeaders(siteId: Long) = listOf(
Header(siteId = siteId, key = "Content-Type", value = "text/html"),
Header(siteId = siteId, key = "User-Agent", value = "NockNock")
)
fun fakeModel(id: Long) = Site(
id = id,
name = "Test",
@ -65,7 +71,8 @@ fun fakeModel(id: Long) = Site(
tags = "",
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id)
retryPolicy = fakeRetryPolicy(id),
headers = fakeHeaders(id)
)
val MOCK_MODEL_1 = fakeModel(1)

View file

@ -19,6 +19,7 @@ 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.Header
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
@ -27,12 +28,13 @@ import com.afollestad.nocknock.data.model.ValidationResult
/** @author Aidan Follestad (@afollestad) */
@Database(
entities = [
Header::class,
RetryPolicy::class,
ValidationResult::class,
SiteSettings::class,
Site::class
],
version = 3,
version = 4,
exportSchema = false
)
@TypeConverters(Converters::class)
@ -45,6 +47,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun validationResultsDao(): ValidationResultsDao
abstract fun retryPolicyDao(): RetryPolicyDao
abstract fun headerDao(): HeaderDao
}
/**
@ -61,10 +65,12 @@ fun AppDatabase.allSites(): List<Site> {
.singleOrNull()
val retryPolicy = retryPolicyDao().forSite(it.id)
.singleOrNull()
val headers = headerDao().forSite(it.id)
return@map it.copy(
settings = settings,
lastResult = lastResult,
retryPolicy = retryPolicy
retryPolicy = retryPolicy,
headers = headers
)
}
}
@ -83,10 +89,12 @@ fun AppDatabase.getSite(id: Long): Site? {
.singleOrNull()
val retryPolicy = retryPolicyDao().forSite(id)
.singleOrNull()
val headers = headerDao().forSite(id)
return result.copy(
settings = settings,
lastResult = lastResult,
retryPolicy = retryPolicy
retryPolicy = retryPolicy,
headers = headers
)
}
@ -101,14 +109,19 @@ fun AppDatabase.putSite(site: Site): Site {
val settingsWithSiteId = settings.copy(siteId = newId)
val lastResultWithSiteId = site.lastResult?.copy(siteId = newId)
val retryPolicyWithSiteId = site.retryPolicy?.copy(siteId = newId)
siteSettingsDao().insert(settingsWithSiteId)
val headersWithSiteId = site.headers.map { it.copy(siteId = newId) }
siteSettingsDao().insert(settingsWithSiteId)
lastResultWithSiteId?.let { validationResultsDao().insert(it) }
retryPolicyWithSiteId?.let { retryPolicyDao().insert(it) }
headerDao().insert(headersWithSiteId)
return site.copy(
id = newId,
settings = settingsWithSiteId
settings = settingsWithSiteId,
lastResult = lastResultWithSiteId,
retryPolicy = retryPolicyWithSiteId,
headers = headersWithSiteId
)
}
@ -152,6 +165,13 @@ fun AppDatabase.updateSite(site: Site) {
retryPolicyDao().insert(retryPolicy)
}
}
// Wipe existing headers
headerDao().delete(headerDao().forSite(site.id))
// Then add ones that still exist
site.headers.forEach { header ->
headerDao().insert(header.copy(id = 0, siteId = site.id))
}
}
/**
@ -163,5 +183,9 @@ fun AppDatabase.deleteSite(site: Site) {
site.settings?.let { siteSettingsDao().delete(it) }
site.lastResult?.let { validationResultsDao().delete(it) }
site.retryPolicy?.let { retryPolicyDao().delete(it) }
if (site.headers.any { it.id == 0L }) {
throw IllegalStateException("Cannot delete header with ID = 0.")
}
headerDao().delete(site.headers)
siteDao().delete(site)
}

View file

@ -43,3 +43,17 @@ class Database2to3Migration : Migration(2, 3) {
database.execSQL("ALTER TABLE `sites` ADD COLUMN tags TEXT NOT NULL DEFAULT ''")
}
}
/**
* Migrates the database from version 3 to 4.
*
* @author Aidan Follestad (@afollestad)
*/
class Database3to4Migration : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE `headers` (id INTEGER PRIMARY KEY NOT NULL, siteId INTEGER NOT NULL, `key` TEXT NOT NULL, value TEXT NOT NULL)"
)
}
}

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

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.
*/
@file:Suppress("unused")
package com.afollestad.nocknock.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.io.Serializable
/**
* Represents an HTTP header that is sent with a site's validation attempts.
*
* @author Aidan Follestad (@afollestad)
*/
@Entity(tableName = "headers")
data class Header(
/** The header's unique datrabase ID. */
@PrimaryKey(autoGenerate = true) var id: Long = 0,
/** The [Site] this header belong to. */
var siteId: Long = 0,
/** The header key/name. */
var key: String = "",
/** The header value. */
var value: String = ""
) : Serializable {
constructor() : this(0, 0, "", "")
}

View file

@ -40,10 +40,12 @@ data class Site(
/** The last validation attempt result for the site, if any. */
@Ignore var lastResult: ValidationResult?,
/** The site's retry policy, if any. */
@Ignore var retryPolicy: RetryPolicy?
@Ignore var retryPolicy: RetryPolicy?,
/** Request headers sent with this site's validation attempts. */
@Ignore var headers: List<Header>
) : CanNotifyModel {
constructor() : this(0, "", "", "", null, null, null)
constructor() : this(0, "", "", "", null, null, null, emptyList())
override fun notifyId(): Int = id.toInt()

View file

@ -15,14 +15,14 @@
*/
package com.afollestad.nocknock.engine
import com.afollestad.nocknock.engine.validation.RealValidationManager
import com.afollestad.nocknock.engine.validation.ValidationManager
import com.afollestad.nocknock.engine.validation.RealValidationExecutor
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import org.koin.dsl.module.module
/** @author Aidan Follestad (@afollestad) */
val engineModule = module {
single {
RealValidationManager(get(), get(), get(), get(), get(), get())
} bind ValidationManager::class
RealValidationExecutor(get(), get(), get(), get(), get(), get())
} bind ValidationExecutor::class
}

View file

@ -32,7 +32,7 @@ import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */
class BootReceiver : BroadcastReceiver(), KoinComponent {
private val validationManager by inject<ValidationManager>()
private val validationManager by inject<ValidationExecutor>()
private val mainDispatcher by inject<CoroutineDispatcher>(name = MAIN_DISPATCHER)
private val ioDispatcher by inject<CoroutineDispatcher>(name = IO_DISPATCHER)
@ -48,7 +48,7 @@ class BootReceiver : BroadcastReceiver(), KoinComponent {
val pendingResult = goAsync()
GlobalScope.launch(mainDispatcher) {
withContext(ioDispatcher) { validationManager.ensureScheduledChecks() }
withContext(ioDispatcher) { validationManager.ensureScheduledValidations() }
pendingResult.resultCode = 0
pendingResult.finish()
}

View file

@ -43,11 +43,11 @@ data class CheckResult(
typealias ClientTimeoutChanger = (client: OkHttpClient, timeout: Int) -> OkHttpClient
/** @author Aidan Follestad (@afollestad) */
interface ValidationManager {
interface ValidationExecutor {
suspend fun ensureScheduledChecks()
suspend fun ensureScheduledValidations()
fun scheduleCheck(
fun scheduleValidation(
site: Site,
rightNow: Boolean = false,
cancelPrevious: Boolean = rightNow,
@ -55,19 +55,19 @@ interface ValidationManager {
overrideDelay: Long = -1
)
fun cancelCheck(site: Site)
fun cancelScheduledValidation(site: Site)
suspend fun performCheck(site: Site): CheckResult
suspend fun performValidation(site: Site): CheckResult
}
class RealValidationManager(
class RealValidationExecutor(
private val jobScheduler: JobScheduler,
private val okHttpClient: OkHttpClient,
private val stringProvider: StringProvider,
private val bundleProvider: BundleProvider,
private val jobInfoProvider: JobInfoProvider,
private val database: AppDatabase
) : ValidationManager {
) : ValidationExecutor {
private var clientTimeoutChanger: ClientTimeoutChanger = { client, timeout ->
client.newBuilder()
@ -75,37 +75,37 @@ class RealValidationManager(
.build()
}
override suspend fun ensureScheduledChecks() {
override suspend fun ensureScheduledValidations() {
val sites = database.allSites()
if (sites.isEmpty()) {
return
}
log("Ensuring enabled sites have scheduled checks.")
log("Ensuring enabled sites have scheduled validations.")
sites.filter { it.settings?.disabled != true }
.forEach { site ->
val existingJob = jobForSite(site)
if (existingJob == null) {
log("Site ${site.id} does NOT have a scheduled job, running one now.")
scheduleCheck(site = site, rightNow = true)
scheduleValidation(site = site, rightNow = true)
} else {
log("Site ${site.id} already has a scheduled job. Nothing to do.")
}
}
}
override fun scheduleCheck(
override fun scheduleValidation(
site: Site,
rightNow: Boolean,
cancelPrevious: Boolean,
fromFinishingJob: Boolean,
overrideDelay: Long
) {
check(site.id != 0L) { "Cannot schedule checks for jobs with no ID." }
check(site.id != 0L) { "Cannot schedule validations for jobs with no ID." }
val siteSettings = site.settings
requireNotNull(siteSettings) { "Site settings must be populated." }
if (cancelPrevious) {
cancelCheck(site)
cancelScheduledValidation(site)
} else if (!fromFinishingJob) {
val existingJob = jobForSite(site)
check(existingJob == null) {
@ -113,7 +113,7 @@ class RealValidationManager(
}
}
log("Requesting a check job for site to be scheduled: $site")
log("Requesting a validation job for site to be scheduled: $site")
val extras = bundleProvider.createPersistable {
putLong(KEY_SITE_ID, site.id)
}
@ -131,28 +131,33 @@ class RealValidationManager(
val dispatchResult = jobScheduler.schedule(jobInfo)
if (dispatchResult != RESULT_SUCCESS) {
log("Failed to schedule a check job for site: ${site.id}")
log("Failed to schedule a validation job for site: ${site.id}")
} else {
log("Check job successfully scheduled for site: ${site.id}")
log("Validation job successfully scheduled for site: ${site.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}")
override fun cancelScheduledValidation(site: Site) {
check(site.id != 0L) { "Cannot cancel scheduled validations for jobs with no ID." }
log("Cancelling scheduled validations for site: ${site.id}")
jobScheduler.cancel(site.id.toInt())
}
override suspend fun performCheck(site: Site): CheckResult {
check(site.id != 0L) { "Cannot schedule checks for jobs with no ID." }
override suspend fun performValidation(site: Site): CheckResult {
check(site.id != 0L) { "Cannot schedule validations 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}")
log("performValidation(${site.id}) - GET ${site.url}")
val request = Request.Builder()
.url(site.url)
.get()
.apply {
url(site.url)
get()
site.headers.forEach { header ->
addHeader(header.key, header.value)
}
}
.build()
return try {
@ -161,13 +166,13 @@ class RealValidationManager(
.execute()
if (response.isSuccessful || response.code() == 401) {
log("performCheck(${site.id}) = Successful")
log("performValidation(${site.id}) = Successful")
CheckResult(
model = site.withStatus(status = OK, reason = null),
response = response
)
} else {
log("performCheck(${site.id}) = Failure, HTTP code ${response.code()}")
log("performValidation(${site.id}) = Failure, HTTP code ${response.code()}")
CheckResult(
model = site.withStatus(
status = ERROR,
@ -177,7 +182,7 @@ class RealValidationManager(
)
}
} catch (timeoutEx: SocketTimeoutException) {
log("performCheck(${site.id}) = Socket Timeout")
log("performValidation(${site.id}) = Socket Timeout")
CheckResult(
model = site.withStatus(
status = ERROR,
@ -185,7 +190,7 @@ class RealValidationManager(
)
)
} catch (ex: Exception) {
log("performCheck(${site.id}) = Error: ${ex.message}")
log("performValidation(${site.id}) = Error: ${ex.message}")
CheckResult(model = site.withStatus(status = ERROR, reason = ex.message))
}
}

View file

@ -60,7 +60,7 @@ class ValidationJob : JobService() {
}
private val database by inject<AppDatabase>()
private val validationManager by inject<ValidationManager>()
private val validationManager by inject<ValidationExecutor>()
private val notificationManager by inject<NockNotificationManager>()
override fun onStartJob(params: JobParameters): Boolean {
@ -83,7 +83,7 @@ class ValidationJob : JobService() {
val jobResult = async(IO) {
updateStatus(site, CHECKING)
val checkResult = validationManager.performCheck(site)
val checkResult = validationManager.performValidation(site)
val resultModel = checkResult.model
val resultResponse = checkResult.response
val result = resultModel.lastResult!!
@ -153,7 +153,7 @@ class ValidationJob : JobService() {
updateTriesLeft(retryPolicy, retryPolicy.triesLeft)
val interval = retryPolicy.interval()
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = jobResult,
fromFinishingJob = true,
overrideDelay = interval
@ -170,7 +170,7 @@ class ValidationJob : JobService() {
notificationManager.postStatusNotification(jobResult)
}
validationManager.scheduleCheck(
validationManager.scheduleValidation(
site = jobResult,
fromFinishingJob = true
)

View file

@ -19,6 +19,7 @@ dependencies {
implementation project(':data')
implementation 'androidx.appcompat:appcompat:' + versions.androidxCore
implementation 'com.google.android.material:material:' + versions.googleMaterial
api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle
api 'com.squareup.okhttp3:okhttp:' + versions.okHttp

View file

@ -0,0 +1,65 @@
/**
* 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.headers
import android.content.Context
import android.util.AttributeSet
import android.widget.LinearLayout
import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.viewcomponents.R
import kotlinx.android.synthetic.main.header_stack_item_content.view.inputKey
import kotlinx.android.synthetic.main.header_stack_item_content.view.inputValue
/** @author Aidan Follestad (@afollestad) */
class HeaderItemLayout(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
private var header: Header? = null
private var stack: HeaderStackLayout? = null
init {
z
orientation = HORIZONTAL
inflate(context, R.layout.header_stack_item_content, this)
}
fun attachHeader(
newHeader: Header,
parentStack: HeaderStackLayout
) {
this.header = newHeader
this.stack = parentStack
inputKey.run {
setText(newHeader.key)
onTextChanged {
header?.key = it.trim()
stack?.postLiveData()
}
}
inputValue.run {
setText(newHeader.value)
onTextChanged {
header?.value = it.trim()
stack?.postLiveData()
}
}
}
}

View file

@ -0,0 +1,82 @@
/**
* 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.headers
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.View.OnClickListener
import android.widget.LinearLayout
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.viewcomponents.R
import kotlinx.android.synthetic.main.header_stack_item_content.view.btnRemove
import kotlinx.android.synthetic.main.header_stack_item_content.view.inputKey
import kotlinx.android.synthetic.main.header_stack_item_content.view.inputValue
import kotlinx.android.synthetic.main.header_stack_layout.view.addHeader
import kotlinx.android.synthetic.main.header_stack_layout.view.header_list as list
/** @author Aidan Follestad (@afollestad) */
class HeaderStackLayout(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs), OnClickListener {
private var data: MutableLiveData<List<Header>>? = null
private var headers = mutableListOf<Header>()
init {
orientation = VERTICAL
inflate(context, R.layout.header_stack_layout, this)
addHeader.setOnClickListener { addEntry(Header()) }
}
fun attach(data: MutableLiveData<List<Header>>) {
list.removeAllViews()
headers.clear()
data.value?.forEach(::addEntry)
this.data = data
}
fun postLiveData() = this.data?.postValue(headers)
override fun onClick(v: View) {
val index = v.tag as Int
list.removeViewAt(index)
headers.removeAt(index)
postLiveData()
}
private fun addEntry(forHeader: Header) {
// Keep track of reference for posting future changes.
headers.add(forHeader)
val li = LayoutInflater.from(context)
val entry = li.inflate(R.layout.header_stack_item, list, false) as HeaderItemLayout
list.addView(entry)
entry.run {
inputKey.setText(forHeader.key)
inputKey.post { entry.inputKey.requestFocus() }
attachHeader(forHeader, this@HeaderStackLayout)
inputValue.setText(forHeader.value)
btnRemove.tag = headers.size - 1
btnRemove.setOnClickListener(this@HeaderStackLayout)
}
}
}

View file

@ -25,8 +25,7 @@ import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.WEEK
import com.afollestad.nocknock.viewcomponents.R.array
import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
@ -48,18 +47,18 @@ class ValidationIntervalLayout(
init {
orientation = VERTICAL
inflate(context, layout.validation_interval_layout, this)
inflate(context, R.layout.validation_interval_layout, this)
}
override fun onFinishInflate() {
super.onFinishInflate()
val spinnerAdapter = ArrayAdapter(
context,
layout.list_item_spinner,
resources.getStringArray(array.interval_options)
R.layout.list_item_spinner,
resources.getStringArray(R.array.interval_options)
)
spinnerAdapter.setDropDownViewResource(
layout.list_item_spinner_dropdown
R.layout.list_item_spinner_dropdown
)
spinner.adapter = spinnerAdapter
}

View file

@ -0,0 +1,10 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp">
<path
android:fillColor="?android:textColorPrimary"
android:pathData="M8.59,16.34l4.58,-4.59 -4.58,-4.59L10,5.75l6,6 -6,6z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0"
android:width="24dp">
<path
android:fillColor="?android:textColorPrimary"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<com.afollestad.nocknock.viewcomponents.headers.HeaderItemLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>

View file

@ -0,0 +1,60 @@
<?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"
tools:parentTag="android.widget.LinearLayout"
>
<EditText
android:id="@+id/inputKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="@dimen/content_inset_quarter"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:hint="@string/header_name"
android:inputType="text"
android:nextFocusDown="@+id/inputValue"
android:nextFocusRight="@+id/inputValue"
android:singleLine="true"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:focusable="false"
android:focusableInTouchMode="false"
android:src="@drawable/ic_chevron_right"
tools:ignore="ContentDescription"
/>
<EditText
android:id="@+id/inputValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="@dimen/content_inset_quarter"
android:layout_weight="1"
android:hint="@string/header_value"
android:inputType="text"
android:singleLine="true"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btnRemove"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/content_inset_half"
android:background="?selectableItemBackground"
android:src="@drawable/ic_close"
/>
</merge>

View file

@ -0,0 +1,37 @@
<?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"
>
<com.google.android.material.button.MaterialButton
android:id="@+id/addHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/add_header"
style="@style/AccentTextButton"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lineSpacingMultiplier="1.2"
android:text="@string/header_desc"
style="@style/NockText.Body.Light"
/>
<LinearLayout
android:id="@+id/header_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
android:gravity="center"
android:orientation="vertical"
tools:ignore="UselessLeaf"
/>
</merge>

View file

@ -19,17 +19,25 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:weightSum="2"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/check_interval_every"
style="@style/NockText.Body"
/>
<EditText
android:id="@+id/input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginEnd="@dimen/content_inset_half"
android:layout_marginStart="-4dp"
android:layout_marginEnd="@dimen/content_inset_less"
android:layout_marginStart="@dimen/content_inset_less"
android:layout_weight="1"
android:hint="0"
android:inputType="number"

View file

@ -6,6 +6,7 @@
<string name="function_end">}</string>
<string name="check_interval">Check Interval</string>
<string name="check_interval_every">Every</string>
<string name="retry_policy">Retry Policy</string>
<string name="retry_policy_retry">Retry</string>
@ -16,4 +17,10 @@
values. After retrying %1$d times over %2$d minutes with no success, you will get a notification.
</string>
<string name="headers">Headers</string>
<string name="add_header">Add Header…</string>
<string name="header_desc">Add HTTP headers to each request made to validate this site.</string>
<string name="header_name">Header Name</string>
<string name="header_value">Header Value</string>
</resources>

View file

@ -9,6 +9,12 @@
<item name="android:textColor">?colorAccent</item>
</style>
<style name="NockText.Header">
<item name="android:textSize">@dimen/title_font_size</item>
<item name="android:fontFamily">@font/lato_bold</item>
<item name="android:textColor">?android:textColorPrimary</item>
</style>
<style name="NockText.Title">
<item name="android:textSize">@dimen/title_font_size</item>
<item name="android:fontFamily">@font/lato</item>