Configurable response timeouts, resolves #31

This commit is contained in:
Aidan Follestad 2018-12-02 13:36:46 -08:00
parent 7e46b84d08
commit 62ef385b65
15 changed files with 220 additions and 40 deletions

View file

@ -22,12 +22,14 @@ import com.afollestad.nocknock.viewcomponents.ext.conceal
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onLayout
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.textAsInt
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
import kotlinx.android.synthetic.main.activity_addsite.inputName
import kotlinx.android.synthetic.main.activity_addsite.inputUrl
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.rootView
@ -99,6 +101,7 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
val defaultTimeout = getString(R.string.response_timeout_default).toInt()
isClosing = true
presenter.commit(
@ -106,7 +109,8 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView {
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationMode.validationContent()
validationContent = validationMode.validationContent(),
networkTimeout = responseTimeoutInput.textAsInt(defaultValue = defaultTimeout)
)
}
}
@ -165,6 +169,11 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView {
null
}
)
responseTimeoutInput.error = if (errors.networkTimeout != null) {
getString(errors.networkTimeout!!)
} else {
null
}
}
override fun onSiteAdded() {

View file

@ -27,11 +27,12 @@ data class InputErrors(
var url: Int? = null,
var checkInterval: Int? = null,
var termSearch: Int? = null,
var javaScript: Int? = null
var javaScript: Int? = null,
var networkTimeout: Int? = null
) {
@CheckResult fun any(): Boolean {
return name != null || url != null || checkInterval != null ||
termSearch != null || javaScript != null
termSearch != null || javaScript != null || networkTimeout != null
}
}
@ -52,7 +53,8 @@ interface AddSitePresenter {
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
validationContent: String?,
networkTimeout: Int
)
fun dropView()
@ -106,7 +108,8 @@ class RealAddSitePresenter @Inject constructor(
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
validationContent: String?,
networkTimeout: Int
) {
val inputErrors = InputErrors()
@ -126,6 +129,9 @@ class RealAddSitePresenter @Inject constructor(
} else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) {
inputErrors.javaScript = R.string.please_enter_javaScript
}
if (networkTimeout <= 0) {
inputErrors.networkTimeout = R.string.please_enter_networkTimeout
}
if (inputErrors.any()) {
view?.setInputErrors(inputErrors)
@ -138,7 +144,8 @@ class RealAddSitePresenter @Inject constructor(
status = WAITING,
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationContent
validationContent = validationContent,
networkTimeout = networkTimeout
)
with(view!!) {

View file

@ -33,6 +33,7 @@ import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.textAsInt
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton
@ -41,6 +42,7 @@ import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
import kotlinx.android.synthetic.main.activity_viewsite.inputName
import kotlinx.android.synthetic.main.activity_viewsite.inputUrl
import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress
import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_viewsite.rootView
@ -113,13 +115,15 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
val defaultTimeout = getString(R.string.response_timeout_default).toInt()
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationMode.validationContent()
validationContent = validationMode.validationContent(),
networkTimeout = responseTimeoutInput.textAsInt(defaultValue = defaultTimeout)
)
}
@ -189,6 +193,8 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
}
}
responseTimeoutInput.setText(model.networkTimeout.toString())
disableChecksButton.showOrHide(!this.disabled)
doneBtn.setText(
if (this.disabled) R.string.renable_and_save_changes
@ -228,6 +234,11 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
null
}
)
responseTimeoutInput.error = if (errors.networkTimeout != null) {
getString(errors.networkTimeout!!)
} else {
null
}
}
override fun scopeWhileAttached(

View file

@ -34,11 +34,12 @@ data class InputErrors(
var url: Int? = null,
var checkInterval: Int? = null,
var termSearch: Int? = null,
var javaScript: Int? = null
var javaScript: Int? = null,
var networkTimeout: Int? = null
) {
@CheckResult fun any(): Boolean {
return name != null || url != null || checkInterval != null ||
termSearch != null || javaScript != null
termSearch != null || javaScript != null || networkTimeout != null
}
}
@ -66,7 +67,8 @@ interface ViewSitePresenter {
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
validationContent: String?,
networkTimeout: Int
)
fun checkNow()
@ -151,7 +153,8 @@ class RealViewSitePresenter @Inject constructor(
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
validationContent: String?,
networkTimeout: Int
) {
val inputErrors = InputErrors()
@ -171,6 +174,9 @@ class RealViewSitePresenter @Inject constructor(
} else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) {
inputErrors.javaScript = R.string.please_enter_javaScript
}
if (networkTimeout <= 0) {
inputErrors.networkTimeout = R.string.please_enter_networkTimeout
}
if (inputErrors.any()) {
view?.setInputErrors(inputErrors)
@ -184,7 +190,8 @@ class RealViewSitePresenter @Inject constructor(
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationContent,
disabled = false
disabled = false,
networkTimeout = networkTimeout
)
with(view!!) {

View file

@ -100,6 +100,28 @@
android:layout_marginTop="@dimen/content_inset"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
android:textColor="#FFFFFF"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body.Light"
/>
<TextView
android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content"

View file

@ -122,6 +122,28 @@
android:layout_marginTop="@dimen/content_inset"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
android:textColor="#FFFFFF"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body.Light"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"

View file

@ -25,6 +25,7 @@
<string name="please_enter_check_interval">Please input a check interval.</string>
<string name="please_enter_search_term">Please input a search term.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
<string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string>
<string name="options">Options</string>
<string name="already_checking_sites">Already checking sites!</string>
@ -49,6 +50,9 @@
<string name="disable">Disable</string>
<string name="renable_and_save_changes">Enable Checks &amp; Save Changes</string>
<string name="response_timeout">Network Response Timeout</string>
<string name="response_timeout_default">10000</string>
<string name="refresh_status">Refresh Status</string>
<string name="warning_http_url">

View file

@ -113,7 +113,8 @@ class AddSitePresenterTest {
"https://test.com",
1,
STATUS_CODE,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -131,7 +132,8 @@ class AddSitePresenterTest {
"",
1,
STATUS_CODE,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -149,7 +151,8 @@ class AddSitePresenterTest {
"ftp://hello.com",
1,
STATUS_CODE,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -167,7 +170,8 @@ class AddSitePresenterTest {
"https://hello.com",
-1,
STATUS_CODE,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -185,7 +189,8 @@ class AddSitePresenterTest {
"https://hello.com",
1,
TERM_SEARCH,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -197,13 +202,33 @@ class AddSitePresenterTest {
assertThat(errors.termSearch).isEqualTo(R.string.please_enter_search_term)
}
@Test fun commit_networkTimeout_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
STATUS_CODE,
null,
0
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.networkTimeout).isEqualTo(R.string.please_enter_networkTimeout)
}
@Test fun commit_javaScript_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
JAVASCRIPT,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -221,7 +246,8 @@ class AddSitePresenterTest {
"https://hello.com",
1,
STATUS_CODE,
null
null,
60000
)
val modelCaptor = argumentCaptor<ServerModel>()

View file

@ -156,7 +156,8 @@ class ViewSitePresenterTest {
"https://test.com",
1,
STATUS_CODE,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -174,7 +175,8 @@ class ViewSitePresenterTest {
"",
1,
STATUS_CODE,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -192,7 +194,8 @@ class ViewSitePresenterTest {
"ftp://hello.com",
1,
STATUS_CODE,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -210,7 +213,8 @@ class ViewSitePresenterTest {
"https://hello.com",
-1,
STATUS_CODE,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -228,7 +232,8 @@ class ViewSitePresenterTest {
"https://hello.com",
1,
TERM_SEARCH,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -246,7 +251,8 @@ class ViewSitePresenterTest {
"https://hello.com",
1,
JAVASCRIPT,
null
null,
60000
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
@ -258,6 +264,25 @@ class ViewSitePresenterTest {
assertThat(errors.javaScript).isEqualTo(R.string.please_enter_javaScript)
}
@Test fun commit_networkTimeout_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
STATUS_CODE,
null,
0
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.networkTimeout).isEqualTo(R.string.please_enter_networkTimeout)
}
@Test fun commit_success() = runBlocking {
val name = "Testing"
val url = "https://hello.com"
@ -274,7 +299,8 @@ class ViewSitePresenterTest {
url,
checkInterval,
validationMode,
validationContent
validationContent,
60000
)
val modelCaptor = argumentCaptor<ServerModel>()
@ -358,4 +384,4 @@ class ViewSitePresenterTest {
on { getAction() } doReturn action
}
}
}
}

View file

@ -27,7 +27,8 @@ data class ServerModel(
val reason: String? = null,
val validationMode: ValidationMode,
val validationContent: String? = null,
val disabled: Boolean = false
val disabled: Boolean = false,
val networkTimeout: Int = 0
) : IdProvider {
companion object {
@ -42,6 +43,7 @@ data class ServerModel(
const val COLUMN_VALIDATION_MODE = "validation_mode"
const val COLUMN_VALIDATION_CONTENT = "validation_content"
const val COLUMN_DISABLED = "disabled"
const val COLUMN_NETWORK_TIMEOUT = "network_timeout"
const val DEFAULT_SORT_ORDER = "$COLUMN_NAME ASC, $COLUMN_DISABLED DESC"
@ -58,7 +60,8 @@ data class ServerModel(
cursor.getColumnIndex(COLUMN_VALIDATION_MODE)
).toValidationMode(),
validationContent = cursor.getString(cursor.getColumnIndex(COLUMN_VALIDATION_CONTENT)),
disabled = cursor.getInt(cursor.getColumnIndex(COLUMN_DISABLED)) == 1
disabled = cursor.getInt(cursor.getColumnIndex(COLUMN_DISABLED)) == 1,
networkTimeout = cursor.getInt(cursor.getColumnIndex(COLUMN_NETWORK_TIMEOUT))
)
}
}
@ -81,5 +84,6 @@ data class ServerModel(
put(COLUMN_VALIDATION_MODE, validationMode.value)
put(COLUMN_VALIDATION_CONTENT, validationContent)
put(COLUMN_DISABLED, disabled)
put(COLUMN_NETWORK_TIMEOUT, networkTimeout)
}
}

View file

@ -21,7 +21,9 @@ private const val SQL_CREATE_ENTRIES =
"${ServerModel.COLUMN_REASON} TEXT," +
"${ServerModel.COLUMN_VALIDATION_MODE} INTEGER," +
"${ServerModel.COLUMN_VALIDATION_CONTENT} TEXT," +
"${ServerModel.COLUMN_DISABLED} INTEGER)"
"${ServerModel.COLUMN_DISABLED} INTEGER," +
"${ServerModel.COLUMN_NETWORK_TIMEOUT} INTEGER" +
")"
private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${ServerModel.TABLE_NAME}"
@ -30,7 +32,7 @@ class ServerModelDbHelper(context: Context) : SQLiteOpenHelper(
context, DATABASE_NAME, null, DATABASE_VERSION
) {
companion object {
const val DATABASE_VERSION = 2
const val DATABASE_VERSION = 3
const val DATABASE_NAME = "ServerModels.db"
}
@ -43,8 +45,12 @@ class ServerModelDbHelper(context: Context) : SQLiteOpenHelper(
oldVersion: Int,
newVersion: Int
) {
db.execSQL(SQL_DELETE_ENTRIES)
onCreate(db)
if (newVersion == 3 && oldVersion == 2) {
db.execSQL(
"ALTER TABLE ${ServerModel.TABLE_NAME} " +
"ADD COLUMN ${ServerModel.COLUMN_NETWORK_TIMEOUT} INTEGER DEFAULT 10000"
)
}
}
override fun onDowngrade(

View file

@ -19,7 +19,9 @@ import com.afollestad.nocknock.utilities.providers.StringProvider
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jetbrains.annotations.TestOnly
import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit.MILLISECONDS
import javax.inject.Inject
import timber.log.Timber.d as log
@ -29,6 +31,8 @@ data class CheckResult(
val response: Response? = null
)
typealias ClientTimeoutChanger = (client: OkHttpClient, timeout: Int) -> OkHttpClient
/** @author Aidan Follestad (@afollestad) */
interface CheckStatusManager {
@ -55,6 +59,12 @@ class RealCheckStatusManager @Inject constructor(
private val siteStore: ServerModelStore
) : CheckStatusManager {
private var clientTimeoutChanger: ClientTimeoutChanger = { client, timeout ->
client.newBuilder()
.callTimeout(timeout.toLong(), MILLISECONDS)
.build()
}
override suspend fun ensureScheduledChecks() {
val sites = siteStore.get()
if (sites.isEmpty()) {
@ -121,6 +131,7 @@ class RealCheckStatusManager @Inject constructor(
override suspend fun performCheck(site: ServerModel): CheckResult {
check(site.id != 0) { "Cannot schedule checks for jobs with no ID." }
check(site.networkTimeout > 0) { "Network timeout not set for site ${site.id}" }
log("performCheck(${site.id}) - GET ${site.url}")
val request = Request.Builder()
@ -129,8 +140,10 @@ class RealCheckStatusManager @Inject constructor(
.build()
return try {
val response = okHttpClient.newCall(request)
val client = clientTimeoutChanger(okHttpClient, site.networkTimeout)
val response = client.newCall(request)
.execute()
if (response.isSuccessful || response.code() == 401) {
log("performCheck(${site.id}) = Successful")
CheckResult(
@ -164,4 +177,8 @@ class RealCheckStatusManager @Inject constructor(
private fun jobForSite(site: ServerModel) =
jobScheduler.allPendingJobs
.firstOrNull { job -> job.id == site.id }
@TestOnly fun setClientTimeoutChanger(changer: ClientTimeoutChanger) {
this.clientTimeoutChanger = changer
}
}

View file

@ -55,7 +55,12 @@ class CheckStatusManagerTest {
bundleProvider,
jobInfoProvider,
store
)
).apply {
setClientTimeoutChanger { _, timeout ->
whenever(okHttpClient.callTimeoutMillis()).doReturn(timeout)
return@setClientTimeoutChanger okHttpClient
}
}
@Test fun ensureScheduledChecks_noEnabledSites() = runBlocking {
val model1 = fakeModel().copy(disabled = true)
@ -234,6 +239,8 @@ class CheckStatusManagerTest {
reason = null
)
)
assertThat(okHttpClient.callTimeoutMillis())
.isEqualTo(model1.networkTimeout)
}
@Test fun performCheck_401_butStillSuccess() = runBlocking {
@ -280,7 +287,8 @@ class CheckStatusManagerTest {
id = 1,
name = "Wakanda Forever",
url = "https://www.wakanda.gov",
validationMode = STATUS_CODE
validationMode = STATUS_CODE,
networkTimeout = 60000
)
private fun fakeJob(id: Int): JobInfo {

View file

@ -24,7 +24,13 @@ fun Long.timeString() = when {
"${ceil((this.toFloat() / DAY.toFloat()).toDouble()).toInt()}d"
this >= HOUR ->
"${ceil((this.toFloat() / HOUR.toFloat()).toDouble()).toInt()}h"
this >= MINUTE ->
"${ceil((this.toFloat() / MINUTE.toFloat()).toDouble()).toInt()}m"
this >= MINUTE -> {
val result = "${ceil((this.toFloat() / MINUTE.toFloat()).toDouble()).toInt()}m"
if (result == "60m") {
"1h"
} else {
result
}
}
else -> "<1m"
}

View file

@ -9,9 +9,14 @@ import android.widget.TextView
fun TextView.trimmedText() = text.toString().trim()
fun TextView.textAsLong(): Long {
fun TextView.textAsInt(defaultValue: Int = 0): Int {
val text = trimmedText()
return if (text.isEmpty()) 0L else text.toLong()
return if (text.isEmpty()) defaultValue else text.toInt()
}
fun TextView.textAsLong(defaultValue: Long = 0L): Long {
val text = trimmedText()
return if (text.isEmpty()) defaultValue else text.toLong()
}
///** @author https://stackoverflow.com/a/53296137/309644 */