diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 17bc1fa..0000000 --- a/.editorconfig +++ /dev/null @@ -1,3 +0,0 @@ -[*.kt] -indent_size = 2 -continuation_indent_size=4 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index a574b92..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ -(`[x]` becomes a filled in checkbox, `[ ]` is an empty one) - -- [ ] I have verified there are [no duplicate active or recent bugs, questions, or requests](https://github.com/afollestad/nock-nock/issues?q=is%3Aissue+is%3Aclosed) -- [ ] I have given my issue a non-generic title. - ---- - -If this is a improvement or feature request, you can remove everything below. -Also, please consider making a pull request if you are capable of contributing. - -###### Include the following: - - - Nock Nock version: `0.x.x` - - Affected device: Google Pixel 3 XL with Android 9.0 - ---- - -###### Reproduction Steps - -1. - ---- - -###### Expected Result - ---- - -###### Actual Result \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 0000000..2d7d09f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Something is crashing or not working as intended + +--- + +*Please consider making a Pull Request if you are capable of doing so.* + +**App Version:** + +x.x.x + +**Affected Device(s):** + +Google Pixel 3 XL with Android 9.0 + +**Describe the Bug:** + +A clear description of what is the bug is. + +**To Reproduce:** +1. +2. +3. + +**Expected Behavior:** + +A clear description of what you expected to happen. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md new file mode 100644 index 0000000..77310ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +*Please consider making a Pull Request if you are capable of doing so.* + +**Description what you'd like to happen:** + +A clear description if the feature or behavior you'd like implemented. + +**Describe alternatives you've considered:** + +A clear description of any alternative solutions you've considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/pull_request_template.md similarity index 78% rename from .github/PULL_REQUEST_TEMPLATE.md rename to .github/pull_request_template.md index b4035a9..6307e10 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/pull_request_template.md @@ -1,9 +1,8 @@ - ### Guidelines -1. You must run the `spotlessApply` task before commiting, either through Android Studio or with `./gradlew spotlessApply`. +1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`. 2. A PR should be focused and contained. If you are changing multiple unrelated things, they should be in separate PRs. 3. A PR should fix a bug or solve a problem - something that only you would use is not necessarily something that should be published. 4. Give your PR a detailed title and description - look over your code one last time before actually creating the PR. Give it a self-review. -**If you do not follow the guidelines, your PR will be rejected.** \ No newline at end of file +**If you do not follow the guidelines, your PR will be rejected.** diff --git a/.gitignore b/.gitignore index 161128f..454e51a 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,6 @@ gradle-app.setting .gradletasknamecache # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties \ No newline at end of file +# gradle/wrapper/gradle-wrapper.properties + +app/google-services.json \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 9d1e235..50f0406 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -40,7 +40,7 @@ - + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 40a7b55..0000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: android -jdk: oraclejdk8 -before_script: - - echo no | android create avd --force -n test -t android-22 --abi armeabi-v7a - - emulator -avd test -no-audio -no-window & - - android-wait-for-emulator - - adb shell input keyevent 82 & -android: - components: - - tools - - platform-tools - - build-tools-28.0.3 - - android-28 - - extra-android-support - - extra-android-m2repository - - extra-google-m2repository - - licenses: - - '.+' diff --git a/README.md b/README.md index 8e2ecb5..84ee16c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ ## Nock Nock -[![Build Status](https://travis-ci.org/afollestad/nock-nock.svg)](https://travis-ci.org/afollestad/nock-nock) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html) -![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcasemain3.png) +![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png) Nock Nock is a simple app which allows you to monitor your websites for maximum uptime. diff --git a/app/build.gradle b/app/build.gradle index 7e04aaf..d6b315e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,18 +4,6 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-android-extensions' -apply plugin: 'io.fabric' - -def getFabricApiKey() { - def propsFile = project.rootProject.file('local.properties') - if (!propsFile.exists()) { - return "" - } - Properties properties = new Properties() - properties.load(propsFile.newDataInputStream()) - return properties.getProperty("fabric.apikey") ?: "" -} - android { compileSdkVersion versions.compileSdk buildToolsVersion versions.buildTools @@ -26,16 +14,15 @@ android { targetSdkVersion versions.compileSdk versionCode versions.publishVersionCode versionName versions.publishVersion - manifestPlaceholders = [fabricKey:getFabricApiKey()] } - buildTypes { - debug { - buildConfigField "String", "FABRIC_API_KEY", "\"\"" - } - release { - buildConfigField "String", "FABRIC_API_KEY", "\"${getFabricApiKey()}\"" - } + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } + + packagingOptions { + exclude 'META-INF/atomicfu.kotlin_module' } } @@ -51,6 +38,7 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView implementation 'com.google.android.material:material:' + versions.googleMaterial implementation 'androidx.browser:browser:' + versions.androidxBrowser + implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore // Lifecycle kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle @@ -84,4 +72,8 @@ dependencies { androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner } -apply from: '../spotless.gradle' \ No newline at end of file +apply from: '../spotless.gradle' +apply from: '../mock/mock.gradle' + +apply plugin: "io.fabric" +apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b68a968..332578e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,9 +50,6 @@ - diff --git a/app/src/main/java/com/afollestad/nocknock/AppExt.kt b/app/src/main/java/com/afollestad/nocknock/AppExt.kt index c7af855..828e01f 100644 --- a/app/src/main/java/com/afollestad/nocknock/AppExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/AppExt.kt @@ -20,12 +20,12 @@ import android.app.Application import android.app.Application.ActivityLifecycleCallbacks import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri import android.os.Bundle import androidx.browser.customtabs.CustomTabsIntent import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.HtmlCompat.fromHtml import com.afollestad.materialdialogs.utils.MDUtil.resolveColor +import com.afollestad.nocknock.utilities.ext.toUri import com.afollestad.nocknock.utilities.ui.toast typealias ActivityLifeChange = (activity: Activity, resumed: Boolean) -> Unit @@ -57,8 +57,6 @@ fun Application.onActivityLifeChange(cb: ActivityLifeChange) { fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY) -fun String.toUri() = Uri.parse(this)!! - fun Activity.viewUrl(url: String) { val customTabsIntent = CustomTabsIntent.Builder() .apply { diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt index 2cd0af3..3c29301 100644 --- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt +++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt @@ -47,10 +47,8 @@ class NockNockApp : Application() { Timber.plant(DebugTree()) } - if (BuildConfig.FABRIC_API_KEY.isNotEmpty()) { - Timber.plant(FabricTree()) - Fabric.with(this, Crashlytics()) - } + Timber.plant(FabricTree()) + Fabric.with(this, Crashlytics()) val modules = listOf( prefModule, 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..07f6410 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,8 @@ 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.data.Database4to5Migration 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 +43,9 @@ val mainModule = module { databaseBuilder(get(), AppDatabase::class.java, "NockNock.db") .addMigrations( Database1to2Migration(), - Database2to3Migration() + Database2to3Migration(), + Database3to4Migration(), + Database4to5Migration() ) .build() } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt index c43b729..3220567 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt @@ -15,10 +15,15 @@ */ package com.afollestad.nocknock.ui +import android.content.res.Configuration +import android.os.Build import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.afollestad.nocknock.R import com.afollestad.nocknock.koin.PREF_DARK_MODE +import com.afollestad.nocknock.ui.NightMode.DISABLED +import com.afollestad.nocknock.ui.NightMode.ENABLED +import com.afollestad.nocknock.ui.NightMode.UNKNOWN import com.afollestad.nocknock.utilities.rx.attachLifecycle import com.afollestad.rxkprefs.Pref import org.koin.android.ext.android.inject @@ -35,16 +40,35 @@ abstract class DarkModeSwitchActivity : AppCompatActivity() { setTheme(themeRes()) super.onCreate(savedInstanceState) - darkModePref.observe() - .filter { it != isDarkModeEnabled } - .subscribe { - log("Theme changed, recreating Activity.") - recreate() - } - .attachLifecycle(this) + if (getCurrentNightMode() == UNKNOWN) { + darkModePref.observe() + .filter { it != isDarkModeEnabled } + .subscribe { + log("Theme changed, recreating Activity.") + recreate() + } + .attachLifecycle(this) + } } - protected fun isDarkMode() = darkModePref.get() + protected fun getCurrentNightMode(): NightMode { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return UNKNOWN + } + return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> return ENABLED + Configuration.UI_MODE_NIGHT_NO -> return DISABLED + else -> UNKNOWN + } + } + + protected fun isDarkMode(): Boolean { + return when (getCurrentNightMode()) { + ENABLED -> true + DISABLED -> false + else -> darkModePref.get() + } + } protected fun toggleDarkMode() = setDarkMode(!isDarkMode()) diff --git a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt new file mode 100644 index 0000000..2930fea --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt @@ -0,0 +1,26 @@ +/** + * 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.ui + +/** @author Aidan Follestad (@afollestad) */ +enum class NightMode { + /** Night mode is on at the system level. */ + ENABLED, + /** Night mode is off at the system level. */ + DISABLED, + /** We don't know about night mode, fallback to custom impl. */ + UNKNOWN +} 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..e15a29f 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 @@ -16,17 +16,30 @@ package com.afollestad.nocknock.ui.addsite import android.annotation.SuppressLint +import android.content.Intent +import android.content.Intent.ACTION_OPEN_DOCUMENT +import android.content.Intent.CATEGORY_OPENABLE import android.os.Bundle import android.widget.ArrayAdapter +import androidx.lifecycle.Observer import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.ValidationMode import com.afollestad.nocknock.ui.DarkModeSwitchActivity +import com.afollestad.nocknock.ui.viewsite.KEY_SITE +import com.afollestad.nocknock.utilities.ext.onTextChanged +import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection +import com.afollestad.nocknock.utilities.livedata.distinct +import com.afollestad.nocknock.viewcomponents.ext.dimenFloat +import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition +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 com.afollestad.vvalidator.form +import com.afollestad.vvalidator.form.Form 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,47 +49,54 @@ 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.sslCertificateBrowse +import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput 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) */ class AddSiteActivity : DarkModeSwitchActivity() { + companion object { + private const val SELECT_CERT_FILE_RQ = 23 + } private val viewModel by viewModel() + private lateinit var validationForm: Form @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_addsite) setupUi() + setupValidation() lifecycle.addObserver(viewModel) + // Populate view model with initial data + val model = intent.getSerializableExtra(KEY_SITE) as? Site + model?.let { viewModel.prePopulateFromModel(model) } + // Loading loadingProgress.observe(this, viewModel.onIsLoading()) // Name inputName.attachLiveData(this, viewModel.name) - viewModel.onNameError() - .toViewError(this, inputName) // Tags inputTags.attachLiveData(this, viewModel.tags) // Url inputUrl.attachLiveData(this, viewModel.url) - viewModel.onUrlError() - .toViewError(this, inputUrl) viewModel.onUrlWarningVisibility() .toViewVisibility(this, textUrlWarning) // Timeout responseTimeoutInput.attachLiveData(this, viewModel.timeout) - viewModel.onTimeoutError() - .toViewError(this, responseTimeoutInput) // Validation mode responseValidationMode.attachLiveData( @@ -85,8 +105,6 @@ class AddSiteActivity : DarkModeSwitchActivity() { outTransformer = { ValidationMode.fromIndex(it) }, inTransformer = { it.toIndex() } ) - viewModel.onValidationSearchTermError() - .toViewError(this, responseValidationSearchTerm) viewModel.onValidationModeDescription() .toViewText(this, validationModeDescription) @@ -99,30 +117,19 @@ class AddSiteActivity : DarkModeSwitchActivity() { viewModel.onValidationSearchTermVisibility() .toViewVisibility(this, responseValidationSearchTerm) - // Validation script - scriptInputLayout.attach( - codeData = viewModel.validationScript, - errorData = viewModel.onValidationScriptError(), - visibility = viewModel.onValidationScriptVisibility() - ) + // SSL certificate + sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } + viewModel.certificateUri.distinct() + .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - // Check interval - checkIntervalLayout.attach( - valueData = viewModel.checkIntervalValue, - multiplierData = viewModel.checkIntervalUnit, - errorData = viewModel.onCheckIntervalError() - ) - - // Retry Policy - retryPolicyLayout.attach( - 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) setNavigationIcon(R.drawable.ic_action_close) setNavigationOnClickListener { finish() } } @@ -135,12 +142,94 @@ 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 } } + + // SSL certificate + sslCertificateBrowse.setOnClickListener { + val intent = Intent(ACTION_OPEN_DOCUMENT).apply { + addCategory(CATEGORY_OPENABLE) + type = "*/*" + } + startActivityForResult(intent, SELECT_CERT_FILE_RQ) + } + } + + private fun setupValidation() { + validationForm = form { + input(inputName, name = "Name") { + isNotEmpty().description(R.string.please_enter_name) + } + input(inputUrl, name = "URL") { + isNotEmpty().description(R.string.please_enter_url) + isUrl().description(R.string.please_enter_valid_url) + } + input(responseTimeoutInput, name = "Timeout", optional = true) { + isNumber().greaterThan(0) + .description(R.string.please_enter_networkTimeout) + } + input(responseValidationSearchTerm, name = "Search term") { + conditional(responseValidationSearchTerm.isVisibleCondition()) { + isNotEmpty().description(R.string.please_enter_search_term) + } + } + input(sslCertificateInput, name = "Certificate Path", optional = true) { + isUri().hasScheme("file", "content") + .that { it.host != null } + .description(R.string.please_enter_validCertUri) + } + submitWith(toolbar.menu, R.id.commit) { + viewModel.commit { + setResult(RESULT_OK) + finish() + } + } + } + + // Validation script + scriptInputLayout.attach( + codeData = viewModel.validationScript, + visibility = viewModel.onValidationScriptVisibility(), + form = validationForm + ) + + // Check interval + checkIntervalLayout.attach( + valueData = viewModel.checkIntervalValue, + multiplierData = viewModel.checkIntervalUnit, + form = validationForm + ) + + // Retry Policy + retryPolicyLayout.attach( + timesData = viewModel.retryPolicyTimes, + minutesData = viewModel.retryPolicyMinutes, + form = validationForm + ) + } + + override fun onResume() { + super.onResume() + appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { + appToolbar.dimenFloat(R.dimen.default_elevation) + } else { + 0f + } + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + resultData: Intent? + ) { + super.onActivityResult(requestCode, resultCode, resultData) + if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) { + sslCertificateInput.setText(resultData?.data?.toString() ?: "") + } } } 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..d7d8ed5 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,11 +36,10 @@ 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 -import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -49,7 +49,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 +66,8 @@ class AddSiteViewModel( val checkIntervalUnit = MutableLiveData() val retryPolicyTimes = MutableLiveData() val retryPolicyMinutes = MutableLiveData() + val headers = MutableLiveData>() + val certificateUri = MutableLiveData() @OnLifecycleEvent(ON_START) fun setDefaults() { @@ -76,24 +78,13 @@ class AddSiteViewModel( retryPolicyMinutes.value = 0 retryPolicyMinutes.value = 0 tags.value = "" + headers.value = emptyList() } - // Private properties private val isLoading = MutableLiveData() - private val nameError = MutableLiveData() - private val urlError = MutableLiveData() - private val timeoutError = MutableLiveData() - private val validationSearchTermError = MutableLiveData() - private val validationScriptError = MutableLiveData() - private val checkIntervalValueError = MutableLiveData() - // Expose private properties or calculated properties @CheckResult fun onIsLoading(): LiveData = isLoading - @CheckResult fun onNameError(): LiveData = nameError - - @CheckResult fun onUrlError(): LiveData = urlError - @CheckResult fun onUrlWarningVisibility(): LiveData { return url.map { val parsed = HttpUrl.parse(it) @@ -101,8 +92,6 @@ class AddSiteViewModel( } } - @CheckResult fun onTimeoutError(): LiveData = timeoutError - @CheckResult fun onValidationModeDescription(): LiveData { return validationMode.map { when (it!!) { @@ -113,17 +102,9 @@ class AddSiteViewModel( } } - @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError + @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - @CheckResult fun onValidationSearchTermVisibility() = - validationMode.map { it == TERM_SEARCH } - - @CheckResult fun onValidationScriptError(): LiveData = validationScriptError - - @CheckResult fun onValidationScriptVisibility() = - validationMode.map { it == JAVASCRIPT } - - @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError + @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } // Actions fun commit(done: () -> Unit) { @@ -134,7 +115,7 @@ class AddSiteViewModel( val storedModel = withContext(ioDispatcher) { database.putSite(newModel) } - validationManager.scheduleCheck( + validationManager.scheduleValidation( site = storedModel, rightNow = true, cancelPrevious = true @@ -163,70 +144,7 @@ class AddSiteViewModel( } private fun generateDbModel(): Site? { - var errorCount = 0 - - // Validation name - if (name.value.isNullOrEmpty()) { - nameError.value = R.string.please_enter_name - errorCount++ - } else { - nameError.value = null - } - - // Validate URL - when { - url.value.isNullOrEmpty() -> { - urlError.value = R.string.please_enter_url - errorCount++ - } - HttpUrl.parse(url.value!!) == null -> { - urlError.value = R.string.please_enter_valid_url - errorCount++ - } - else -> { - urlError.value = null - } - } - - // Validate timeout val timeout = timeout.value ?: 10_000 - if (timeout < 0) { - timeoutError.value = R.string.please_enter_networkTimeout - errorCount++ - } else { - timeoutError.value = null - } - - // Validate check interval - if (checkIntervalValue.value.isNullOrLessThan(1)) { - checkIntervalValueError.value = R.string.please_enter_check_interval - errorCount++ - } else { - checkIntervalValueError.value = null - } - - // Validate arguments - if (validationMode.value == TERM_SEARCH && - validationSearchTerm.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = R.string.please_enter_search_term - validationScriptError.value = null - } else if (validationMode.value == JAVASCRIPT && - validationScript.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = null - validationScriptError.value = R.string.please_enter_javaScript - } else { - validationSearchTermError.value = null - validationScriptError.value = null - } - - if (errorCount > 0) { - return null - } - val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" val newSettings = SiteSettings( @@ -234,7 +152,8 @@ class AddSiteViewModel( validationMode = validationMode.value!!, validationArgs = getValidationArgs(), networkTimeout = timeout, - disabled = false + disabled = false, + certificate = certificateUri.value?.toString() ) val newLastResult = ValidationResult( @@ -247,7 +166,8 @@ class AddSiteViewModel( val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { RetryPolicy( - count = retryPolicyTimes, minutes = retryPolicyMinutes + count = retryPolicyTimes, + minutes = retryPolicyMinutes ) } else { null @@ -260,7 +180,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/addsite/AddSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt new file mode 100644 index 0000000..c524555 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt @@ -0,0 +1,99 @@ +/** + * 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.ui.addsite + +import com.afollestad.nocknock.data.model.RetryPolicy +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +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 kotlin.math.ceil + +fun AddSiteViewModel.prePopulateFromModel(site: Site) { + val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!") + + name.value = site.name + tags.value = site.tags + url.value = site.url + timeout.value = settings.networkTimeout + + validationMode.value = settings.validationMode + when (settings.validationMode) { + TERM_SEARCH -> { + validationSearchTerm.value = settings.validationArgs + validationScript.value = null + } + JAVASCRIPT -> { + validationSearchTerm.value = null + validationScript.value = settings.validationArgs + } + else -> { + validationSearchTerm.value = null + validationScript.value = null + } + } + + setCheckInterval(settings.validationIntervalMs) + setRetryPolicy(site.retryPolicy) + headers.value = site.headers +} + +private fun AddSiteViewModel.setCheckInterval(interval: Long) { + when { + interval >= WEEK -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, WEEK) + checkIntervalUnit.value = WEEK + } + interval >= DAY -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, DAY) + checkIntervalUnit.value = DAY + } + interval >= HOUR -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, HOUR) + checkIntervalUnit.value = HOUR + } + interval >= MINUTE -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, MINUTE) + checkIntervalUnit.value = MINUTE + } + else -> { + checkIntervalValue.value = 0 + checkIntervalUnit.value = MINUTE + } + } +} + +private fun AddSiteViewModel.setRetryPolicy(policy: RetryPolicy?) { + if (policy == null) return + retryPolicyTimes.value = policy.count + retryPolicyMinutes.value = policy.minutes +} + +private fun getIntervalFromUnit( + millis: Long, + unit: Long +): Int { + val intervalFloat = millis.toFloat() + val byFloat = unit.toFloat() + return ceil(intervalFloat / byFloat).toInt() +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt index 9f3609a..aec76d8 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt @@ -24,7 +24,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItems -import com.afollestad.materialdialogs.list.listItemsSingleChoice import com.afollestad.nocknock.R import com.afollestad.nocknock.adapter.SiteAdapter import com.afollestad.nocknock.adapter.TagAdapter @@ -33,10 +32,8 @@ import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.dialogs.AboutDialog import com.afollestad.nocknock.notifications.NockNotificationManager import com.afollestad.nocknock.ui.DarkModeSwitchActivity +import com.afollestad.nocknock.ui.NightMode.UNKNOWN import com.afollestad.nocknock.utilities.providers.IntentProvider -import com.afollestad.nocknock.utilities.ui.toast -import com.afollestad.nocknock.viewUrl -import com.afollestad.nocknock.viewUrlWithApp import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.list @@ -93,12 +90,17 @@ class MainActivity : DarkModeSwitchActivity() { toolbar.run { inflateMenu(R.menu.menu_main) menu.findItem(R.id.dark_mode) - .isChecked = isDarkMode() + .apply { + if (getCurrentNightMode() == UNKNOWN) { + isChecked = isDarkMode() + } else { + isVisible = false + } + } setOnMenuItemClickListener { item -> when (item.itemId) { R.id.about -> AboutDialog.show(this@MainActivity) R.id.dark_mode -> toggleDarkMode() - R.id.support_me -> supportMe() } return@setOnMenuItemClickListener true } @@ -135,7 +137,8 @@ class MainActivity : DarkModeSwitchActivity() { listItems(R.array.site_long_options) { _, i, _ -> when (i) { 0 -> viewModel.refreshSite(model) - 1 -> maybeRemoveSite(model) + 1 -> addSiteForDuplication(model) + 2 -> maybeRemoveSite(model) } } } @@ -143,20 +146,4 @@ class MainActivity : DarkModeSwitchActivity() { viewSite(model) } } - - private fun supportMe() { - MaterialDialog(this).show { - title(R.string.support_me) - message(R.string.support_me_message, html = true, lineHeightMultiplier = 1.4f) - listItemsSingleChoice(R.array.donation_options) { _, index, _ -> - when (index) { - 0 -> viewUrl("https://paypal.me/AidanFollestad") - 1 -> viewUrlWithApp("https://cash.me/\$afollestad", pkg = "com.squareup.cash") - 2 -> viewUrlWithApp("https://venmo.com/afollestad", pkg = "com.venmo") - } - toast(R.string.thank_you) - } - positiveButton(R.string.next) - } - } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt index 422cdc5..e11ca08 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt @@ -28,10 +28,23 @@ import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion. internal const val VIEW_SITE_RQ = 6923 internal const val ADD_SITE_RQ = 6969 +// ADD + internal fun MainActivity.addSite() { - startActivityForResult(Intent(this, AddSiteActivity::class.java), ADD_SITE_RQ) + startActivityForResult(intentToAdd(), ADD_SITE_RQ) } +internal fun MainActivity.addSiteForDuplication(site: Site) { + startActivityForResult(intentToAdd(site), ADD_SITE_RQ) +} + +private fun MainActivity.intentToAdd(model: Site? = null) = + Intent(this, AddSiteActivity::class.java).apply { + model?.let { putExtra(KEY_SITE, it) } + } + +// VIEW + internal fun MainActivity.viewSite(model: Site) { startActivityForResult(intentToView(model), VIEW_SITE_RQ) } @@ -41,6 +54,8 @@ private fun MainActivity.intentToView(model: Site) = putExtra(KEY_SITE, model) } +// MISC + internal fun MainActivity.maybeRemoveSite(model: Site) { MaterialDialog(this).show { title(R.string.remove_site) 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..2aa312c 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 @@ -17,6 +17,8 @@ package com.afollestad.nocknock.ui.viewsite import android.annotation.SuppressLint import android.content.Intent +import android.content.Intent.ACTION_OPEN_DOCUMENT +import android.content.Intent.CATEGORY_OPENABLE import android.os.Bundle import android.widget.ArrayAdapter import androidx.lifecycle.Observer @@ -25,16 +27,20 @@ import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.ValidationMode import com.afollestad.nocknock.ui.DarkModeSwitchActivity +import com.afollestad.nocknock.utilities.ext.onTextChanged +import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection +import com.afollestad.nocknock.utilities.livedata.distinct import com.afollestad.nocknock.utilities.providers.IntentProvider import com.afollestad.nocknock.viewcomponents.ext.dimenFloat +import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition 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 com.afollestad.vvalidator.form +import com.afollestad.vvalidator.form.Form 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 @@ -46,6 +52,8 @@ import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearch import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout import kotlinx.android.synthetic.main.activity_viewsite.scrollView +import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateBrowse +import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateInput import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning @@ -53,12 +61,17 @@ import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescriptio import kotlinx.android.synthetic.main.include_app_bar.toolbar import org.koin.android.ext.android.inject 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) */ class ViewSiteActivity : DarkModeSwitchActivity() { + companion object { + private const val SELECT_CERT_FILE_RQ = 23 + } internal val viewModel by viewModel() + private lateinit var validationForm: Form private val intentProvider by inject() private val statusUpdateReceiver by lazy { @@ -71,17 +84,18 @@ class ViewSiteActivity : DarkModeSwitchActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_viewsite) - setupUi() - - lifecycle.run { - addObserver(viewModel) - addObserver(statusUpdateReceiver) - } // Populate view model with initial data val model = intent.getSerializableExtra(KEY_SITE) as Site viewModel.setModel(model) + setupUi() + setupValidation() + lifecycle.run { + addObserver(viewModel) + addObserver(statusUpdateReceiver) + } + // Loading loadingProgress.observe(this, viewModel.onIsLoading()) @@ -93,23 +107,17 @@ class ViewSiteActivity : DarkModeSwitchActivity() { // Name inputName.attachLiveData(this, viewModel.name) - viewModel.onNameError() - .toViewError(this, inputName) // Tags inputTags.attachLiveData(this, viewModel.tags) // Url inputUrl.attachLiveData(this, viewModel.url) - viewModel.onUrlError() - .toViewError(this, inputUrl) viewModel.onUrlWarningVisibility() .toViewVisibility(this, textUrlWarning) // Timeout responseTimeoutInput.attachLiveData(this, viewModel.timeout) - viewModel.onTimeoutError() - .toViewError(this, responseTimeoutInput) // Validation mode responseValidationMode.attachLiveData( @@ -118,8 +126,6 @@ class ViewSiteActivity : DarkModeSwitchActivity() { outTransformer = { ValidationMode.fromIndex(it) }, inTransformer = { it.toIndex() } ) - viewModel.onValidationSearchTermError() - .toViewError(this, responseValidationSearchTerm) viewModel.onValidationModeDescription() .toViewText(this, validationModeDescription) @@ -128,25 +134,13 @@ class ViewSiteActivity : DarkModeSwitchActivity() { viewModel.onValidationSearchTermVisibility() .toViewVisibility(this, responseValidationSearchTerm) - // Validation script - scriptInputLayout.attach( - codeData = viewModel.validationScript, - errorData = viewModel.onValidationScriptError(), - visibility = viewModel.onValidationScriptVisibility() - ) + // SSL certificate + sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } + viewModel.certificateUri.distinct() + .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - // Check interval - checkIntervalLayout.attach( - valueData = viewModel.checkIntervalValue, - multiplierData = viewModel.checkIntervalUnit, - errorData = viewModel.onCheckIntervalError() - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes - ) + // Headers + headersLayout.attach(viewModel.headers) // Last/next check viewModel.onLastCheckResultText() @@ -156,25 +150,30 @@ 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.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,14 +189,95 @@ 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 + // Done item text viewModel.onDoneButtonText() - .toViewText(this, doneBtn) - doneBtn.setOnClickListener { - viewModel.commit { finish() } + .observe(this, Observer { + toolbar.menu.findItem(R.id.commit) + .setTitle(it) + }) + + // SSL certificate + sslCertificateBrowse.setOnClickListener { + val intent = Intent(ACTION_OPEN_DOCUMENT).apply { + addCategory(CATEGORY_OPENABLE) + type = "*/*" + } + startActivityForResult(intent, SELECT_CERT_FILE_RQ) + } + } + + private fun setupValidation() { + validationForm = form { + input(inputName, name = "Name") { + isNotEmpty().description(R.string.please_enter_name) + } + input(inputUrl, name = "URL") { + isNotEmpty().description(R.string.please_enter_url) + isUrl().description(R.string.please_enter_valid_url) + } + input(responseValidationSearchTerm, name = "Search term") { + conditional(responseValidationSearchTerm.isVisibleCondition()) { + isNotEmpty().description(R.string.please_enter_search_term) + } + } + input(responseTimeoutInput, name = "Timeout", optional = true) { + isNumber().greaterThan(0) + .description(R.string.please_enter_networkTimeout) + } + input(sslCertificateInput, name = "Certificate Path", optional = true) { + isUri().hasScheme("file", "content") + .that { it.host != null } + .description(R.string.please_enter_validCertUri) + } + submitWith(toolbar.menu, R.id.commit) { + viewModel.commit { finish() } + } + } + + // Validation script + scriptInputLayout.attach( + codeData = viewModel.validationScript, + visibility = viewModel.onValidationScriptVisibility(), + form = validationForm + ) + + // Check interval + checkIntervalLayout.attach( + valueData = viewModel.checkIntervalValue, + multiplierData = viewModel.checkIntervalUnit, + form = validationForm + ) + + // Retry Policy + retryPolicyLayout.attach( + timesData = viewModel.retryPolicyTimes, + minutesData = viewModel.retryPolicyMinutes, + form = validationForm + ) + } + + override fun onResume() { + super.onResume() + appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { + appToolbar.dimenFloat(R.dimen.default_elevation) + } else { + 0f + } + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + resultData: Intent? + ) { + super.onActivityResult(requestCode, resultCode, resultData) + if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) { + sslCertificateInput.setText(resultData?.data?.toString() ?: "") } } 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..b5c9f93 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 @@ -23,8 +23,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData 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.RetryPolicy import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.data.model.Status import com.afollestad.nocknock.data.model.Status.WAITING @@ -35,14 +36,13 @@ 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 import com.afollestad.nocknock.utilities.livedata.map import com.afollestad.nocknock.utilities.livedata.zip import com.afollestad.nocknock.utilities.providers.StringProvider -import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -54,7 +54,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,25 +74,15 @@ class ViewSiteViewModel( val checkIntervalUnit = MutableLiveData() val retryPolicyTimes = MutableLiveData() val retryPolicyMinutes = MutableLiveData() + val headers = MutableLiveData>() + val certificateUri = MutableLiveData() internal val disabled = MutableLiveData() internal val lastResult = MutableLiveData() - // Private properties private val isLoading = MutableLiveData() - private val nameError = MutableLiveData() - private val urlError = MutableLiveData() - private val timeoutError = MutableLiveData() - private val validationSearchTermError = MutableLiveData() - private val validationScriptError = MutableLiveData() - private val checkIntervalValueError = MutableLiveData() - // Expose private properties or calculated properties @CheckResult fun onIsLoading(): LiveData = isLoading - @CheckResult fun onNameError(): LiveData = nameError - - @CheckResult fun onUrlError(): LiveData = urlError - @CheckResult fun onUrlWarningVisibility(): LiveData { return url.map { val parsed = HttpUrl.parse(it) @@ -100,8 +90,6 @@ class ViewSiteViewModel( } } - @CheckResult fun onTimeoutError(): LiveData = timeoutError - @CheckResult fun onValidationModeDescription(): LiveData { return validationMode.map { when (it!!) { @@ -112,20 +100,11 @@ class ViewSiteViewModel( } } - @CheckResult fun onValidationSearchTermError(): LiveData = validationSearchTermError + @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - @CheckResult fun onValidationSearchTermVisibility() = - validationMode.map { it == TERM_SEARCH } + @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } - @CheckResult fun onValidationScriptError(): LiveData = validationScriptError - - @CheckResult fun onValidationScriptVisibility() = - validationMode.map { it == JAVASCRIPT } - - @CheckResult fun onCheckIntervalError(): LiveData = checkIntervalValueError - - @CheckResult fun onDisableChecksVisibility(): LiveData = - disabled.map { !it } + @CheckResult fun onDisableChecksVisibility(): LiveData = disabled.map { !it } @CheckResult fun onDoneButtonText(): LiveData = disabled.map { @@ -169,7 +148,7 @@ class ViewSiteViewModel( withContext(ioDispatcher) { database.updateSite(updatedModel) } - validationManager.scheduleCheck( + validationManager.scheduleValidation( site = updatedModel, rightNow = true, cancelPrevious = true @@ -185,7 +164,7 @@ class ViewSiteViewModel( status = WAITING ) setModel(checkModel) - validationManager.scheduleCheck( + validationManager.scheduleValidation( site = checkModel, rightNow = true, cancelPrevious = true @@ -193,7 +172,7 @@ class ViewSiteViewModel( } fun removeSite(done: () -> Unit) { - validationManager.cancelCheck(site) + validationManager.cancelScheduledValidation(site) notificationManager.cancelStatusNotification(site) scope.launch { @@ -207,7 +186,7 @@ class ViewSiteViewModel( } fun disableSite() { - validationManager.cancelCheck(site) + validationManager.cancelScheduledValidation(site) notificationManager.cancelStatusNotification(site) scope.launch { @@ -243,78 +222,16 @@ class ViewSiteViewModel( } private fun getUpdatedDbModel(): Site? { - var errorCount = 0 - - // Validation name - if (name.value.isNullOrEmpty()) { - nameError.value = R.string.please_enter_name - errorCount++ - } else { - nameError.value = null - } - - // Validate URL - when { - url.value.isNullOrEmpty() -> { - urlError.value = R.string.please_enter_url - errorCount++ - } - HttpUrl.parse(url.value!!) == null -> { - urlError.value = R.string.please_enter_valid_url - errorCount++ - } - else -> { - urlError.value = null - } - } - - // Validate timeout val timeout = timeout.value ?: 10_000 - if (timeout < 0) { - timeoutError.value = R.string.please_enter_networkTimeout - errorCount++ - } else { - timeoutError.value = null - } - - // Validate check interval - if (checkIntervalValue.value.isNullOrLessThan(1)) { - checkIntervalValueError.value = R.string.please_enter_check_interval - errorCount++ - } else { - checkIntervalValueError.value = null - } - - // Validate arguments - if (validationMode.value == TERM_SEARCH && - validationSearchTerm.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = R.string.please_enter_search_term - validationScriptError.value = null - } else if (validationMode.value == JAVASCRIPT && - validationScript.value.isNullOrEmpty() - ) { - errorCount++ - validationSearchTermError.value = null - validationScriptError.value = R.string.please_enter_javaScript - } else { - validationSearchTermError.value = null - validationScriptError.value = null - } - - if (errorCount > 0) { - return null - } - - val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" + val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: "" val newSettings = site.settings!!.copy( validationIntervalMs = getCheckIntervalMs(), validationMode = validationMode.value!!, validationArgs = getValidationArgs(), networkTimeout = timeout, - disabled = false + disabled = false, + certificate = certificateUri.value?.toString() ) val retryPolicyTimes = retryPolicyTimes.value ?: 0 @@ -322,11 +239,15 @@ class ViewSiteViewModel( val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { if (site.retryPolicy != null) { // Have existing policy, update it - site.retryPolicy!!.copy(count = retryPolicyTimes, minutes = retryPolicyMinutes) + site.retryPolicy!!.copy( + count = retryPolicyTimes, + minutes = retryPolicyMinutes + ) } else { // Create new policy RetryPolicy( - count = retryPolicyTimes, minutes = retryPolicyMinutes + count = retryPolicyTimes, + minutes = retryPolicyMinutes ) } } else { @@ -339,7 +260,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..800f235 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,12 @@ fun ViewSiteViewModel.setModel(site: Site) { setCheckInterval(settings.validationIntervalMs) setRetryPolicy(site.retryPolicy) + headers.value = site.headers + if (settings.certificate == "null") { + certificateUri.value = "" + } else { + certificateUri.value = settings.certificate + } this.disabled.value = settings.disabled this.lastResult.value = site.lastResult @@ -63,22 +69,22 @@ private fun ViewSiteViewModel.setCheckInterval(interval: Long) { when { interval >= WEEK -> { checkIntervalValue.value = - getIntervalFromUnit(interval, WEEK) + getIntervalFromUnit(interval, WEEK) checkIntervalUnit.value = WEEK } interval >= DAY -> { checkIntervalValue.value = - getIntervalFromUnit(interval, DAY) + getIntervalFromUnit(interval, DAY) checkIntervalUnit.value = DAY } interval >= HOUR -> { checkIntervalValue.value = - getIntervalFromUnit(interval, HOUR) + getIntervalFromUnit(interval, HOUR) checkIntervalUnit.value = HOUR } interval >= MINUTE -> { checkIntervalValue.value = - getIntervalFromUnit(interval, MINUTE) + getIntervalFromUnit(interval, MINUTE) checkIntervalUnit.value = MINUTE } else -> { 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..80f83da 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" > - + - + - + - - - - - + - + - - - + @@ -117,35 +91,10 @@ android:layout_marginTop="@dimen/content_inset" /> - - - - - + - + + + + + + + + + +