diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt index 647281b..d61e13a 100644 --- a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt +++ b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt @@ -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() } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt index 7c46f41..fe8edd1 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt @@ -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 } } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt index 02b3eec..0fbf5a0 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt @@ -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() val retryPolicyTimes = MutableLiveData() val retryPolicyMinutes = MutableLiveData() + val headers = MutableLiveData>() @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() ) } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt index d6c3e90..bad64fc 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt @@ -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() } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt index 9cb4942..f0c7e90 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt @@ -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?) { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt index 0e19c2d..a3e6a83 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt @@ -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() val retryPolicyTimes = MutableLiveData() val retryPolicyMinutes = MutableLiveData() + val headers = MutableLiveData>() internal val disabled = MutableLiveData() internal val lastResult = MutableLiveData() @@ -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) } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt index 9e602d3..b119a7f 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt @@ -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 diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..00fc15d --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml index b300916..7c41db0 100644 --- a/app/src/main/res/layout/activity_addsite.xml +++ b/app/src/main/res/layout/activity_addsite.xml @@ -16,6 +16,7 @@ @@ -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" > - + - + - + - - - - - + - + - - - + @@ -118,34 +92,23 @@ /> - + - + + diff --git a/app/src/main/res/layout/activity_viewsite.xml b/app/src/main/res/layout/activity_viewsite.xml index a4fd0f5..15f8f2e 100644 --- a/app/src/main/res/layout/activity_viewsite.xml +++ b/app/src/main/res/layout/activity_viewsite.xml @@ -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" > + + - - @@ -217,6 +218,16 @@ + + + + + - - - - diff --git a/app/src/main/res/menu/menu_addsite.xml b/app/src/main/res/menu/menu_addsite.xml new file mode 100644 index 0000000..e346eb9 --- /dev/null +++ b/app/src/main/res/menu/menu_addsite.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/menu/menu_viewsite.xml b/app/src/main/res/menu/menu_viewsite.xml index f46e13d..fee6f5a 100644 --- a/app/src/main/res/menu/menu_viewsite.xml +++ b/app/src/main/res/menu/menu_viewsite.xml @@ -1,17 +1,23 @@ - + - - + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 4c19a47..510a2e9 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -2,5 +2,6 @@ 28sp 6dp + 4dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e7c993..56a7c31 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,9 +20,11 @@ Dismiss Add Site Site Name + Site display name Site URL + https://yoursite.com Site Tags - Site Tags (one, two, three) + One,Two,Three Please enter a name! Please enter a URL. Please enter a valid URL. @@ -46,8 +48,8 @@ Disable Automatic Validation %1$s? The site will not be checked in the background - until you re-enable validation for it. You can still manually perform validation by tapping the - Refresh icon at the top of this page. + 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. ]]> Disable Enable Auto Validation & Save Changes diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 22a2da4..a6ce990 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,4 +16,8 @@ @font/lato + + diff --git a/app/src/main/res/values/styles_text.xml b/app/src/main/res/values/styles_text.xml index e0fc849..df25036 100644 --- a/app/src/main/res/values/styles_text.xml +++ b/app/src/main/res/values/styles_text.xml @@ -6,4 +6,28 @@ ?toolbarTitleColor + + + + + + diff --git a/app/src/test/java/com/afollestad/nocknock/TestData.kt b/app/src/test/java/com/afollestad/nocknock/TestData.kt index 74fe5bb..eb1f359 100644 --- a/app/src/test/java/com/afollestad/nocknock/TestData.kt +++ b/app/src/test/java/com/afollestad/nocknock/TestData.kt @@ -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
{ + 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) diff --git a/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt index 9acd165..eb86c29 100644 --- a/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelTest.kt @@ -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() + private val validationManager = mock() @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, diff --git a/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt index 776c39e..1a483af 100644 --- a/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/ui/main/MainViewModelTest.kt @@ -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() - private val validationManager = mock() + private val validationManager = mock() @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!!) diff --git a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt index 04e1c79..39e017b 100644 --- a/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt +++ b/app/src/test/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelTest.kt @@ -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() + private val validationManager = mock() private val notificationManager = mock() @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!!) diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt index bd249bc..463be68 100644 --- a/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt +++ b/data/src/androidTest/java/com/afollestad/nocknock/data/AppDatabaseTest.kt @@ -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() @@ -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()) } } diff --git a/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt index 9cfe214..6fc9796 100644 --- a/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt +++ b/data/src/androidTest/java/com/afollestad/nocknock/data/TestUtil.kt @@ -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) diff --git a/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt index d3421ec..bfceff4 100644 --- a/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/AppDatabase.kt @@ -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 { .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) } diff --git a/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt b/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt index d1587b7..ac2f2af 100644 --- a/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/DatabaseMigrations.kt @@ -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)" + ) + } +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt b/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt new file mode 100644 index 0000000..c34f0cb --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/HeaderDao.kt @@ -0,0 +1,47 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +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
+ + @Query("SELECT * FROM headers WHERE siteId = :siteId") + fun forSite(siteId: Long): List
+ + @Insert(onConflict = FAIL) + fun insert(headers: Header): Long + + @Insert(onConflict = FAIL) + fun insert(headers: List
): List + + @Update(onConflict = FAIL) + fun update(header: Header): Int + + @Delete + fun delete(headers: List
): Int +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt new file mode 100644 index 0000000..9460af0 --- /dev/null +++ b/data/src/main/java/com/afollestad/nocknock/data/model/Header.kt @@ -0,0 +1,42 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@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, "", "") +} diff --git a/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt b/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt index 1fe0f08..f271f29 100644 --- a/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt +++ b/data/src/main/java/com/afollestad/nocknock/data/model/Site.kt @@ -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
) : CanNotifyModel { - constructor() : this(0, "", "", "", null, null, null) + constructor() : this(0, "", "", "", null, null, null, emptyList()) override fun notifyId(): Int = id.toInt() diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt index fbd3a45..da45724 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/EngineModule.kt @@ -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 } diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt index deab303..cc80509 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/BootReceiver.kt @@ -32,7 +32,7 @@ import timber.log.Timber.d as log /** @author Aidan Follestad (@afollestad) */ class BootReceiver : BroadcastReceiver(), KoinComponent { - private val validationManager by inject() + private val validationManager by inject() private val mainDispatcher by inject(name = MAIN_DISPATCHER) private val ioDispatcher by inject(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() } diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt similarity index 76% rename from engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt rename to engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt index 87593fc..6097487 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationManager.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationExecutor.kt @@ -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)) } } diff --git a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt index f1d484f..57a62cd 100644 --- a/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt +++ b/engine/src/main/java/com/afollestad/nocknock/engine/validation/ValidationJob.kt @@ -60,7 +60,7 @@ class ValidationJob : JobService() { } private val database by inject() - private val validationManager by inject() + private val validationManager by inject() private val notificationManager by inject() 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 ) diff --git a/viewcomponents/build.gradle b/viewcomponents/build.gradle index abbff09..8b7cc90 100644 --- a/viewcomponents/build.gradle +++ b/viewcomponents/build.gradle @@ -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 diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderItemLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderItemLayout.kt new file mode 100644 index 0000000..4163bbc --- /dev/null +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderItemLayout.kt @@ -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() + } + } + } +} diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt new file mode 100644 index 0000000..092d416 --- /dev/null +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/headers/HeaderStackLayout.kt @@ -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>? = null + private var headers = mutableListOf
() + + init { + orientation = VERTICAL + inflate(context, R.layout.header_stack_layout, this) + addHeader.setOnClickListener { addEntry(Header()) } + } + + fun attach(data: MutableLiveData>) { + 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) + } + } +} diff --git a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt index 58bbe9d..3436dd5 100644 --- a/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt +++ b/viewcomponents/src/main/java/com/afollestad/nocknock/viewcomponents/interval/ValidationIntervalLayout.kt @@ -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 } diff --git a/viewcomponents/src/main/res/drawable/ic_chevron_right.xml b/viewcomponents/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..3483bbb --- /dev/null +++ b/viewcomponents/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/viewcomponents/src/main/res/drawable/ic_close.xml b/viewcomponents/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..6c28089 --- /dev/null +++ b/viewcomponents/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/viewcomponents/src/main/res/layout/header_stack_item.xml b/viewcomponents/src/main/res/layout/header_stack_item.xml new file mode 100644 index 0000000..c87066f --- /dev/null +++ b/viewcomponents/src/main/res/layout/header_stack_item.xml @@ -0,0 +1,6 @@ + + diff --git a/viewcomponents/src/main/res/layout/header_stack_item_content.xml b/viewcomponents/src/main/res/layout/header_stack_item_content.xml new file mode 100644 index 0000000..d963ccc --- /dev/null +++ b/viewcomponents/src/main/res/layout/header_stack_item_content.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + diff --git a/viewcomponents/src/main/res/layout/header_stack_layout.xml b/viewcomponents/src/main/res/layout/header_stack_layout.xml new file mode 100644 index 0000000..e497e2a --- /dev/null +++ b/viewcomponents/src/main/res/layout/header_stack_layout.xml @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/viewcomponents/src/main/res/layout/validation_interval_layout.xml b/viewcomponents/src/main/res/layout/validation_interval_layout.xml index 2a16de4..31b7519 100644 --- a/viewcomponents/src/main/res/layout/validation_interval_layout.xml +++ b/viewcomponents/src/main/res/layout/validation_interval_layout.xml @@ -19,17 +19,25 @@ + + } Check Interval + Every Retry Policy Retry @@ -16,4 +17,10 @@ values. After retrying %1$d times over %2$d minutes with no success, you will get a notification. + Headers + Add Header… + Add HTTP headers to each request made to validate this site. + Header Name + Header Value + diff --git a/viewcomponents/src/main/res/values/styles.xml b/viewcomponents/src/main/res/values/styles.xml index f3be3fb..ed3ebe2 100644 --- a/viewcomponents/src/main/res/values/styles.xml +++ b/viewcomponents/src/main/res/values/styles.xml @@ -9,6 +9,12 @@ ?colorAccent + +