Compare commits

..

No commits in common. "master" and "0.8.0" have entirely different histories.

244 changed files with 4099 additions and 9435 deletions

28
.github/ISSUE_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,28 @@
(`[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

View file

@ -1,28 +0,0 @@
---
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.

View file

@ -1,15 +0,0 @@
---
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.

View file

@ -1,8 +1,9 @@
### Guidelines
1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`.
1. You must run the `spotlessApply` task before commiting, 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.**
**If you do not follow the guidelines, your PR will be rejected.**

4
.gitignore vendored
View file

@ -180,6 +180,4 @@ gradle-app.setting
.gradletasknamecache
# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
# gradle/wrapper/gradle-wrapper.properties
app/google-services.json
# gradle/wrapper/gradle-wrapper.properties

17
.idea/misc.xml generated
View file

@ -1,16 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CMakeSettings">
<configurations>
<configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" />
</configurations>
</component>
<component name="NullableNotNullManager">
<option name="myDefaultNullable" value="android.support.annotation.Nullable" />
<option name="myDefaultNotNull" value="android.support.annotation.NonNull" />
<option name="myNullables">
<value>
<list size="10">
<list size="7">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.Nullable" />
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nullable" />
<item index="2" class="java.lang.String" itemvalue="javax.annotation.CheckForNull" />
@ -18,29 +13,23 @@
<item index="4" class="java.lang.String" itemvalue="android.support.annotation.Nullable" />
<item index="5" class="java.lang.String" itemvalue="androidx.annotation.Nullable" />
<item index="6" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNullable" />
<item index="7" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.Nullable" />
<item index="8" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableDecl" />
<item index="9" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NullableType" />
</list>
</value>
</option>
<option name="myNotNulls">
<value>
<list size="9">
<list size="6">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.NotNull" />
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nonnull" />
<item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.NonNull" />
<item index="3" class="java.lang.String" itemvalue="android.support.annotation.NonNull" />
<item index="4" class="java.lang.String" itemvalue="androidx.annotation.NonNull" />
<item index="5" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNonNull" />
<item index="6" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.qual.NonNull" />
<item index="7" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullDecl" />
<item index="8" class="java.lang.String" itemvalue="org.checkerframework.checker.nullness.compatqual.NonNullType" />
</list>
</value>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

2
.idea/modules.xml generated
View file

@ -3,11 +3,11 @@
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/app/app.iml" filepath="$PROJECT_DIR$/app/app.iml" />
<module fileurl="file://$PROJECT_DIR$/common/common.iml" filepath="$PROJECT_DIR$/common/common.iml" />
<module fileurl="file://$PROJECT_DIR$/data/data.iml" filepath="$PROJECT_DIR$/data/data.iml" />
<module fileurl="file://$PROJECT_DIR$/engine/engine.iml" filepath="$PROJECT_DIR$/engine/engine.iml" />
<module fileurl="file://$PROJECT_DIR$/nock-nock.iml" filepath="$PROJECT_DIR$/nock-nock.iml" />
<module fileurl="file://$PROJECT_DIR$/notifications/notifications.iml" filepath="$PROJECT_DIR$/notifications/notifications.iml" />
<module fileurl="file://$PROJECT_DIR$/utilities/utilities.iml" filepath="$PROJECT_DIR$/utilities/utilities.iml" />
<module fileurl="file://$PROJECT_DIR$/viewcomponents/viewcomponents.iml" filepath="$PROJECT_DIR$/viewcomponents/viewcomponents.iml" />
</modules>
</component>

22
.travis.yml Normal file
View file

@ -0,0 +1,22 @@
language: android
jdk: oraclejdk8
android:
components:
- tools
- platform-tools
- build-tools-28.0.3
- android-28
- extra-android-support
- extra-android-m2repository
- extra-google-m2repository
# Additional components
#- extra-google-google_play_services
#- addon-google_apis-google-19
# Specify at least one system image, if you need to run emulator(s) during your tests
#- sys-img-armeabi-v7a-android-19
#- sys-img-x86-android-17
licenses:
- '.+'

View file

@ -1,8 +1,9 @@
## 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/showcase5.png)
![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcasemain.png)
Nock Nock is a simple app which allows you to monitor your websites for maximum uptime.
@ -10,4 +11,4 @@ The app will automatically knock on the door of your websites (or web servers) o
to make sure they are up and responding successfully. If something is wrong, you get a notification telling you so.
<br/>
<a href="https://play.google.com/store/apps/details?id=com.afollestad.nocknock&utm_source=global_co&utm_medium=prtnr&utm_content=Mar2515&utm_campaign=PartBadge&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1"><img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" width="200px"/></a>
<a href="https://play.google.com/store/apps/details?id=com.afollestad.nocknock&utm_source=global_co&utm_medium=prtnr&utm_content=Mar2515&utm_campaign=PartBadge&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1"><img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" width="200px"/></a>

View file

@ -15,65 +15,30 @@ android {
versionCode versions.publishVersionCode
versionName versions.publishVersion
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
}
dependencies {
implementation project(':common')
implementation project(':engine')
implementation project(':data')
implementation project(':utilities')
implementation project(':engine')
implementation project(':notifications')
implementation project(':viewcomponents')
// Google/AppCompat
implementation 'androidx.appcompat:appcompat:' + versions.androidxCore
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
implementation 'androidx.appcompat:appcompat:' + versions.androidx
implementation 'androidx.recyclerview:recyclerview:' + versions.androidx
implementation 'com.google.android.material:material:' + versions.androidx
// Lifecycle
kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle
// Kotlin
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
// JOIN
implementation 'org.koin:koin-android:' + versions.koin
implementation 'org.koin:koin-androidx-scope:' + versions.koin
implementation 'org.koin:koin-androidx-viewmodel:' + versions.koin
implementation 'com.google.dagger:dagger:' + versions.dagger
kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
// afollestad
implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
// Debugging
implementation 'com.jakewharton.timber:timber:' + versions.timber
implementation("com.crashlytics.sdk.android:crashlytics:${versions.fabric}") {
transitive = true
}
// Testing
testImplementation 'junit:junit:' + versions.junit
testImplementation 'org.mockito:mockito-core:' + versions.mockito
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin
testImplementation 'com.google.truth:truth:' + versions.truth
testImplementation 'androidx.arch.core:core-testing:' + versions.archTesting
// UI testing
androidTestImplementation 'androidx.test:runner:' + versions.androidxTestRunner
androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner
}
apply from: '../spotless.gradle'
apply from: '../mock/mock.gradle'
apply plugin: "io.fabric"
apply plugin: 'com.google.gms.google-services'
apply from: '../spotless.gradle'

BIN
app/release/NockNock.apk Normal file

Binary file not shown.

View file

@ -31,20 +31,22 @@
android:name="com.afollestad.nocknock.ui.addsite.AddSiteActivity"
android:label="@string/add_site"
android:launchMode="singleTop"
android:theme="@style/AppTheme.Transparent"
android:windowSoftInputMode="stateHidden"/>
<activity
android:name="com.afollestad.nocknock.ui.viewsite.ViewSiteActivity"
android:label="@string/view_site"
android:launchMode="singleTop"
android:theme="@style/AppTheme.Ink"
android:windowSoftInputMode="stateHidden"/>
<service
android:name=".engine.validation.ValidationJob"
android:name=".engine.statuscheck.CheckStatusJob"
android:label="@string/check_service_name"
android:permission="android.permission.BIND_JOB_SERVICE"/>
<receiver android:name=".engine.validation.BootReceiver">
<receiver android:name=".engine.statuscheck.BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>

View file

@ -1,32 +1,16 @@
/**
/*
* Licensed under Apache-2.0
*
* 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
import android.app.Activity
import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.content.ActivityNotFoundException
import android.content.Intent
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
@ -56,37 +40,3 @@ fun Application.onActivityLifeChange(cb: ActivityLifeChange) {
}
fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY)
fun Activity.viewUrl(url: String) {
val customTabsIntent = CustomTabsIntent.Builder()
.apply {
setToolbarColor(resolveColor(this@viewUrl, attr = R.attr.colorPrimary))
}
.build()
try {
customTabsIntent.launchUrl(this, url.toUri())
} catch (_: ActivityNotFoundException) {
toast(R.string.install_web_browser)
}
}
fun Activity.viewUrlWithApp(
url: String,
pkg: String
) {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = url.toUri()
}
val resInfo = packageManager.queryIntentActivities(intent, 0)
for (info in resInfo) {
if (info.activityInfo.packageName.toLowerCase().contains(pkg) ||
info.activityInfo.name.toLowerCase().contains(pkg)
) {
startActivity(intent.apply {
setPackage(info.activityInfo.packageName)
})
return
}
}
viewUrl(url)
}

View file

@ -1,69 +1,62 @@
/**
/*
* Licensed under Apache-2.0
*
* 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
import android.app.Application
import com.afollestad.nocknock.BuildConfig.DEBUG
import com.afollestad.nocknock.engine.engineModule
import com.afollestad.nocknock.koin.mainModule
import com.afollestad.nocknock.koin.prefModule
import com.afollestad.nocknock.koin.viewModelModule
import com.afollestad.nocknock.logging.FabricTree
import android.util.Log
import com.afollestad.nocknock.di.AppComponent
import com.afollestad.nocknock.di.DaggerAppComponent
import com.afollestad.nocknock.engine.statuscheck.BootReceiver
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.notifications.notificationsModule
import com.afollestad.nocknock.utilities.commonModule
import com.crashlytics.android.Crashlytics
import io.fabric.sdk.android.Fabric
import org.koin.android.ext.android.inject
import org.koin.android.ext.android.startKoin
import timber.log.Timber
import timber.log.Timber.DebugTree
import timber.log.Timber.d as log
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.Injector
import com.afollestad.nocknock.utilities.ext.systemService
import okhttp3.OkHttpClient
import javax.inject.Inject
/** @author Aidan Follestad (@afollestad) */
class NockNockApp : Application() {
class NockNockApp : Application(), Injector {
companion object {
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("NockNockApp", message)
}
}
}
private lateinit var appComponent: AppComponent
@Inject lateinit var nockNotificationManager: NockNotificationManager
private var resumedActivities: Int = 0
override fun onCreate() {
super.onCreate()
if (DEBUG) {
Timber.plant(DebugTree())
}
val okHttpClient = OkHttpClient.Builder()
.addNetworkInterceptor { chain ->
val request = chain.request()
.newBuilder()
.addHeader("User-Agent", "com.afollestad.nocknock")
.build()
chain.proceed(request)
}
.build()
Timber.plant(FabricTree())
Fabric.with(this, Crashlytics())
appComponent = DaggerAppComponent.builder()
.application(this)
.okHttpClient(okHttpClient)
.jobScheduler(systemService(JOB_SCHEDULER_SERVICE))
.notificationManager(systemService(NOTIFICATION_SERVICE))
.build()
appComponent.inject(this)
val modules = listOf(
prefModule,
mainModule,
engineModule,
commonModule,
notificationsModule,
viewModelModule
)
startKoin(
androidContext = this,
modules = modules
)
val nockNotificationManager by inject<NockNotificationManager>()
onActivityLifeChange { activity, resumed ->
if (resumed) {
resumedActivities++
@ -76,4 +69,13 @@ class NockNockApp : Application() {
nockNotificationManager.setIsAppOpen(resumedActivities > 0)
}
}
override fun injectInto(target: Any) = when (target) {
is MainActivity -> appComponent.inject(target)
is ViewSiteActivity -> appComponent.inject(target)
is AddSiteActivity -> appComponent.inject(target)
is CheckStatusJob -> appComponent.inject(target)
is BootReceiver -> appComponent.inject(target)
else -> throw IllegalStateException("Can't inject into $target")
}
}

View file

@ -1,30 +1,18 @@
/**
/*
* Licensed under Apache-2.0
*
* 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.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil.calculateDiff
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.isPending
import com.afollestad.nocknock.data.model.textRes
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.isPending
import com.afollestad.nocknock.data.textRes
import com.afollestad.nocknock.utilities.ui.onDebouncedClick
import kotlinx.android.synthetic.main.list_item_server.view.iconStatus
import kotlinx.android.synthetic.main.list_item_server.view.textInterval
@ -32,12 +20,12 @@ import kotlinx.android.synthetic.main.list_item_server.view.textName
import kotlinx.android.synthetic.main.list_item_server.view.textStatus
import kotlinx.android.synthetic.main.list_item_server.view.textUrl
typealias Listener = (model: Site, longClick: Boolean) -> Unit
typealias Listener = (model: ServerModel, longClick: Boolean) -> Unit
/** @author Aidan Follestad (@afollestad) */
class SiteViewHolder constructor(
class ServerVH constructor(
itemView: View,
private val adapter: SiteAdapter
private val adapter: ServerAdapter
) : RecyclerView.ViewHolder(itemView), View.OnLongClickListener {
init {
@ -47,32 +35,24 @@ class SiteViewHolder constructor(
itemView.setOnLongClickListener(this)
}
fun bind(model: Site) {
requireNotNull(model.settings) { "Settings must be populated." }
fun bind(model: ServerModel) {
itemView.textName.text = model.name
itemView.textUrl.text = model.url
itemView.iconStatus.setStatus(model.status)
val lastResult = model.lastResult
if (lastResult != null) {
itemView.iconStatus.setStatus(lastResult.status)
val statusText = lastResult.status.textRes()
if (statusText == 0) {
itemView.textStatus.text = lastResult.reason
} else {
itemView.textStatus.setText(statusText)
}
val statusText = model.status.textRes()
if (statusText == 0) {
itemView.textStatus.text = model.reason
} else {
itemView.iconStatus.setStatus(WAITING)
itemView.textStatus.setText(R.string.none)
itemView.textStatus.setText(statusText)
}
val res = itemView.resources
when {
model.settings?.disabled == true -> {
model.disabled -> {
itemView.textInterval.setText(R.string.checks_disabled)
}
model.lastResult?.status.isPending() -> {
model.status.isPending() -> {
itemView.textInterval.text = res.getString(
R.string.next_check_x,
res.getString(R.string.now)
@ -94,33 +74,70 @@ class SiteViewHolder constructor(
}
/** @author Aidan Follestad (@afollestad) */
class SiteAdapter(private val listener: Listener) : RecyclerView.Adapter<SiteViewHolder>() {
class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<ServerVH>() {
private var models = mutableListOf<Site>()
private val models = mutableListOf<ServerModel>()
internal fun performClick(
index: Int,
longClick: Boolean
) = listener.invoke(models[index], longClick)
fun set(newModels: List<Site>) {
val formerModels = this.models
this.models = newModels.toMutableList()
val diffResult = calculateDiff(SiteDiffCallback(formerModels, this.models))
diffResult.dispatchUpdatesTo(this)
fun add(model: ServerModel) {
models.add(model)
notifyItemInserted(models.size - 1)
}
fun update(target: ServerModel) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
update(i, target)
break
}
}
}
private fun update(
index: Int,
model: ServerModel
) {
models[index] = model
notifyItemChanged(index)
}
fun remove(index: Int) {
models.removeAt(index)
notifyItemRemoved(index)
}
fun remove(target: ServerModel) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
remove(i)
break
}
}
}
fun set(newModels: List<ServerModel>) {
this.models.clear()
if (!newModels.isEmpty()) {
this.models.addAll(newModels)
}
notifyDataSetChanged()
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): SiteViewHolder {
): ServerVH {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_server, parent, false)
return SiteViewHolder(v, this)
return ServerVH(v, this)
}
override fun onBindViewHolder(
holder: SiteViewHolder,
holder: ServerVH,
position: Int
) {
val model = models[position]

View file

@ -1,40 +0,0 @@
/**
* 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.adapter
import androidx.recyclerview.widget.DiffUtil
import com.afollestad.nocknock.data.model.Site
/** @author Aidan Follestad (@afollestad) */
class SiteDiffCallback(
private val oldItems: List<Site>,
private val newItems: List<Site>
) : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int
) = oldItems[oldItemPosition].id == newItems[newItemPosition].id
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
) = oldItems[oldItemPosition] == newItems[newItemPosition]
}

View file

@ -1,115 +0,0 @@
/**
* 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.adapter
import android.graphics.Color.WHITE
import android.view.LayoutInflater
import android.view.View
import android.view.View.OnClickListener
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.TagAdapter.TagViewHolder
import kotlinx.android.synthetic.main.list_item_tag.view.chip
typealias TagsListener = (tags: List<String>) -> Unit
/** @author Aidan Follestad (@afollestad) */
class TagAdapter(
private val listener: TagsListener
) : RecyclerView.Adapter<TagViewHolder>() {
private val tags = mutableListOf<String>()
private val checked = mutableListOf<Int>()
fun set(tags: List<String>) {
this.tags.run {
clear()
addAll(tags)
}
notifyDataSetChanged()
}
fun toggleChecked(index: Int) {
if (checked.contains(index)) {
checked.remove(index)
} else {
checked.add(index)
}
notifyItemChanged(index)
listener.invoke(getCheckedTags())
}
private fun getCheckedTags(): List<String> {
return mutableListOf<String>().apply {
checked.forEach { index -> add(tags[index]) }
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): TagViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_tag, parent, false)
return TagViewHolder(view, this)
}
override fun getItemCount() = tags.size
override fun onBindViewHolder(
holder: TagViewHolder,
position: Int
) {
holder.bind(tags[position], checked.contains(position))
}
/** @author Aidan Follestad (@afollestad) */
class TagViewHolder(
itemView: View,
private val adapter: TagAdapter
) : ViewHolder(itemView), OnClickListener {
override fun onClick(v: View) = adapter.toggleChecked(adapterPosition)
init {
itemView.setOnClickListener(this)
}
fun bind(
name: String,
checked: Boolean
) = itemView.chip.run {
text = name
setTextColor(
if (checked) {
WHITE
} else {
ContextCompat.getColor(itemView.context, R.color.unchecked_chip_text)
}
)
setBackgroundResource(
if (checked) {
R.drawable.checked_chip_selector
} else {
R.drawable.unchecked_chip_selector
}
)
}
}
}

View file

@ -1,68 +0,0 @@
/**
* 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.broadcasts
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.lifecycle.Lifecycle.Event.ON_DESTROY
import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
import androidx.lifecycle.Lifecycle.Event.ON_RESUME
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.utilities.providers.IntentProvider
typealias SiteCallback = (Site) -> Unit
/** @author Aidan Follestad (@afollestad) */
class StatusUpdateIntentReceiver(
private val context: Context,
private val intentProvider: IntentProvider,
private var callback: SiteCallback?
) : LifecycleObserver {
internal val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) {
if (intent.action == ACTION_STATUS_UPDATE) {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site
?: return
callback?.invoke(model)
}
}
}
@OnLifecycleEvent(ON_RESUME)
fun onResume() {
val filter = intentProvider.createFilter(ACTION_STATUS_UPDATE)
context.registerReceiver(intentReceiver, filter)
}
@OnLifecycleEvent(ON_PAUSE)
fun onPause() {
context.unregisterReceiver(intentReceiver)
}
@OnLifecycleEvent(ON_DESTROY)
fun onDestroy() {
callback = null
}
}

View file

@ -0,0 +1,63 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.di
import android.app.Application
import android.app.NotificationManager
import android.app.job.JobScheduler
import com.afollestad.nocknock.NockNockApp
import com.afollestad.nocknock.engine.EngineModule
import com.afollestad.nocknock.engine.statuscheck.BootReceiver
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
import com.afollestad.nocknock.notifications.NotificationsModule
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.UtilitiesModule
import dagger.BindsInstance
import dagger.Component
import okhttp3.OkHttpClient
import javax.inject.Singleton
/** @author Aidan Follestad (@afollestad) */
@Singleton
@Component(
modules = [
MainModule::class,
MainBindModule::class,
EngineModule::class,
NotificationsModule::class,
UtilitiesModule::class
]
)
interface AppComponent {
fun inject(app: NockNockApp)
fun inject(activity: MainActivity)
fun inject(activity: ViewSiteActivity)
fun inject(activity: AddSiteActivity)
fun inject(job: CheckStatusJob)
fun inject(bootReceiver: BootReceiver)
@Component.Builder
interface Builder {
@BindsInstance fun application(application: Application): Builder
@BindsInstance fun okHttpClient(okHttpClient: OkHttpClient): Builder
@BindsInstance fun jobScheduler(jobScheduler: JobScheduler): Builder
@BindsInstance fun notificationManager(notificationManager: NotificationManager): Builder
fun build(): AppComponent
}
}

View file

@ -0,0 +1,39 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.di
import com.afollestad.nocknock.ui.addsite.AddSitePresenter
import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter
import com.afollestad.nocknock.ui.main.MainPresenter
import com.afollestad.nocknock.ui.main.RealMainPresenter
import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter
import com.afollestad.nocknock.ui.viewsite.ViewSitePresenter
import dagger.Binds
import dagger.Module
import javax.inject.Singleton
/** @author Aidan Follestad (@afollestad) */
@Module
abstract class MainBindModule {
@Binds
@Singleton
abstract fun provideMainPresenter(
presenter: RealMainPresenter
): MainPresenter
@Binds
@Singleton
abstract fun provideAddSitePresenter(
presenter: RealAddSitePresenter
): AddSitePresenter
@Binds
@Singleton
abstract fun provideViewSitePresenter(
presenter: RealViewSitePresenter
): ViewSitePresenter
}

View file

@ -0,0 +1,29 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.di
import com.afollestad.nocknock.R
import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.utilities.qualifiers.AppIconRes
import com.afollestad.nocknock.utilities.qualifiers.MainActivityClass
import dagger.Module
import dagger.Provides
import javax.inject.Singleton
/** @author Aidan Follestad (@afollestad) */
@Module
open class MainModule {
@Provides
@Singleton
@AppIconRes
fun provideAppIconRes(): Int = R.mipmap.ic_launcher
@Provides
@Singleton
@MainActivityClass
fun provideMainActivityClass(): Class<*> = MainActivity::class.java
}

View file

@ -1,17 +1,7 @@
/**
/*
* Licensed under Apache-2.0
*
* 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.dialogs
@ -20,7 +10,6 @@ import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R
/** @author Aidan Follestad (@afollestad) */
@ -35,9 +24,8 @@ class AboutDialog : DialogFragment() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = activity ?: throw IllegalStateException("Oh no!")
return MaterialDialog(context)
.title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME))
return MaterialDialog(activity!!)
.title(R.string.about)
.positiveButton(R.string.dismiss)
.message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
}

View file

@ -1,72 +0,0 @@
/**
* 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.koin
import android.app.Application
import android.app.NotificationManager
import android.app.job.JobScheduler
import android.content.Context.JOB_SCHEDULER_SERVICE
import android.content.Context.NOTIFICATION_SERVICE
import androidx.room.Room.databaseBuilder
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.Database1to2Migration
import com.afollestad.nocknock.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
import okhttp3.OkHttpClient
import org.koin.dsl.module.module
val mainActivityCls = MainActivity::class.java
/** @author Aidan Follestad (@afollestad) */
val mainModule = module {
single(name = MAIN_ACTIVITY_CLASS) { mainActivityCls }
single {
databaseBuilder(get(), AppDatabase::class.java, "NockNock.db")
.addMigrations(
Database1to2Migration(),
Database2to3Migration(),
Database3to4Migration(),
Database4to5Migration()
)
.build()
}
single {
OkHttpClient.Builder()
.addNetworkInterceptor { chain ->
val request = chain.request()
.newBuilder()
.addHeader("User-Agent", "com.afollestad.nocknock")
.build()
chain.proceed(request)
}
.build()
}
single<JobScheduler> {
get<Application>().systemService(JOB_SCHEDULER_SERVICE)
}
single<NotificationManager> {
get<Application>().systemService(NOTIFICATION_SERVICE)
}
}

View file

@ -1,32 +0,0 @@
/**
* 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.koin
import com.afollestad.rxkprefs.RxkPrefs
import com.afollestad.rxkprefs.rxkPrefs
import org.koin.dsl.module.module
const val PREF_DARK_MODE = "dark_mode"
/** @author Aidan Follestad (@afollestad) */
val prefModule = module {
single { rxkPrefs(get(), "settings") }
factory(name = PREF_DARK_MODE) {
get<RxkPrefs>().boolean(PREF_DARK_MODE, false)
}
}

View file

@ -1,58 +0,0 @@
/**
* 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.koin
import com.afollestad.nocknock.ui.addsite.AddSiteViewModel
import com.afollestad.nocknock.ui.main.MainViewModel
import com.afollestad.nocknock.ui.viewsite.ViewSiteViewModel
import com.afollestad.nocknock.utilities.Qualifiers.IO_DISPATCHER
import com.afollestad.nocknock.utilities.Qualifiers.MAIN_DISPATCHER
import org.koin.androidx.viewmodel.ext.koin.viewModel
import org.koin.dsl.module.module
/** @author Aidan Follestad (@afollestad) */
val viewModelModule = module {
viewModel {
MainViewModel(
get(),
get(),
get(),
get(name = MAIN_DISPATCHER),
get(name = IO_DISPATCHER)
)
}
viewModel {
AddSiteViewModel(
get(),
get(),
get(name = MAIN_DISPATCHER),
get(name = IO_DISPATCHER)
)
}
viewModel {
ViewSiteViewModel(
get(),
get(),
get(),
get(),
get(name = MAIN_DISPATCHER),
get(name = IO_DISPATCHER)
)
}
}

View file

@ -1,37 +0,0 @@
/**
* 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.logging
import com.crashlytics.android.Crashlytics
import timber.log.Timber
/** @author Aidan Follestad (@afollestad) */
class FabricTree : Timber.Tree() {
override fun log(
priority: Int,
tag: String?,
message: String,
t: Throwable?
) {
if (t != null) {
Crashlytics.setString("crash_tag", tag)
Crashlytics.logException(t)
} else {
Crashlytics.log(priority, tag, message)
}
}
}

View file

@ -1,82 +0,0 @@
/**
* 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
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
import timber.log.Timber.d as log
/** @author Aidan Follestad (afollestad) */
abstract class DarkModeSwitchActivity : AppCompatActivity() {
private var isDarkModeEnabled: Boolean = false
private val darkModePref by inject<Pref<Boolean>>(name = PREF_DARK_MODE)
override fun onCreate(savedInstanceState: Bundle?) {
isDarkModeEnabled = isDarkMode()
setTheme(themeRes())
super.onCreate(savedInstanceState)
if (getCurrentNightMode() == UNKNOWN) {
darkModePref.observe()
.filter { it != isDarkModeEnabled }
.subscribe {
log("Theme changed, recreating Activity.")
recreate()
}
.attachLifecycle(this)
}
}
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())
private fun setDarkMode(darkMode: Boolean) = darkModePref.set(darkMode)
private fun themeRes() = if (isDarkMode()) {
R.style.AppTheme_Dark
} else {
R.style.AppTheme
}
}

View file

@ -1,26 +0,0 @@
/**
* 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
}

View file

@ -1,36 +0,0 @@
/**
* 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
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import org.jetbrains.annotations.TestOnly
/** @author Aidan Follestad (@afollestad) */
abstract class ScopedViewModel(mainDispatcher: CoroutineDispatcher) : ViewModel() {
private val job = Job()
protected val scope = CoroutineScope(job + mainDispatcher)
override fun onCleared() {
super.onCleared()
job.cancel()
}
@TestOnly open fun destroy() = job.cancel()
}

View file

@ -1,137 +1,88 @@
/**
/*
* Licensed under Apache-2.0
*
* 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 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 androidx.appcompat.app.AppCompatActivity
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.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form
import com.afollestad.vvalidator.form.Form
import com.afollestad.nocknock.data.ValidationMode
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.indexToValidationMode
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.conceal
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onLayout
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.headersLayout
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
import kotlinx.android.synthetic.main.activity_addsite.inputName
import kotlinx.android.synthetic.main.activity_addsite.inputTags
import kotlinx.android.synthetic.main.activity_addsite.inputUrl
import kotlinx.android.synthetic.main.activity_addsite.loadingProgress
import kotlinx.android.synthetic.main.activity_addsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_addsite.rootView
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.toolbar
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
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlin.math.max
import kotlin.properties.Delegates.notNull
const val KEY_FAB_X = "fab_x"
const val KEY_FAB_Y = "fab_y"
const val KEY_FAB_SIZE = "fab_size"
/** @author Aidan Follestad (@afollestad) */
class AddSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
class AddSiteActivity : AppCompatActivity(), AddSiteView {
private val viewModel by viewModel<AddSiteViewModel>()
private lateinit var validationForm: Form
var isClosing: Boolean = false
var revealCx by notNull<Int>()
var revealCy by notNull<Int>()
var revealRadius by notNull<Float>()
@Inject lateinit var presenter: AddSitePresenter
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_addsite)
setupUi()
setupValidation()
presenter.takeView(this)
lifecycle.addObserver(viewModel)
toolbar.setNavigationOnClickListener { closeActivityWithReveal() }
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as? Site
model?.let { viewModel.prePopulateFromModel(model) }
if (savedInstanceState == null) {
rootView.conceal()
rootView.onLayout {
val fabSize = intent.getIntExtra(KEY_FAB_SIZE, 0)
val fabX = intent.getFloatExtra(KEY_FAB_X, 0f)
.toInt()
val fabY = intent.getFloatExtra(KEY_FAB_Y, 0f)
.toInt()
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
revealCx = fabX + fabSize / 2
revealCy = (fabY + toolbar.measuredHeight + fabSize / 2)
revealRadius = max(revealCx, revealCy).toFloat()
// Name
inputName.attachLiveData(this, viewModel.name)
circularRevealActivity()
}
}
// Tags
inputTags.attachLiveData(this, viewModel.tags)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
// Validation mode
responseValidationMode.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationMode,
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
// Validation search term
responseValidationSearchTerm.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationSearchTerm,
pullInChanges = false
)
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// SSL certificate
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
viewModel.certificateUri.distinct()
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
// 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() }
inputUrl.setOnFocusChangeListener { _, hasFocus ->
presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
}
val validationOptionsAdapter = ArrayAdapter(
@ -140,96 +91,98 @@ class AddSiteActivity : DarkModeSwitchActivity() {
resources.getStringArray(R.array.response_validation_options)
)
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
doneBtn.setOnClickListener {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
// SSL certificate
sslCertificateBrowse.setOnClickListener {
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
addCategory(CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
isClosing = true
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationMode.validationContent()
)
}
}
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 onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun onResume() {
super.onResume()
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
override fun setLoading() = loadingProgress.setLoading()
override fun setDoneLoading() = loadingProgress.setDone()
override fun showOrHideUrlSchemeWarning(show: Boolean) {
textUrlWarning.showOrHide(show)
if (show) {
textUrlWarning.setText(R.string.warning_http_url)
}
}
override fun showOrHideValidationSearchTerm(show: Boolean) =
responseValidationSearchTerm.showOrHide(show)
override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show)
override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res)
override fun setInputErrors(errors: InputErrors) {
isClosing = false
inputName.error = if (errors.name != null) {
getString(errors.name!!)
} else {
0f
null
}
inputUrl.error = if (errors.url != null) {
getString(errors.url!!)
} else {
null
}
checkIntervalLayout.setError(
if (errors.checkInterval != null) {
getString(errors.checkInterval!!)
} else {
null
}
)
responseValidationSearchTerm.error = if (errors.termSearch != null) {
getString(errors.termSearch!!)
} else {
null
}
scriptInputLayout.setError(
if (errors.javaScript != null) {
getString(errors.javaScript!!)
} else {
null
}
)
}
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() ?: "")
}
override fun onSiteAdded() {
setResult(RESULT_OK)
finish()
overridePendingTransition(R.anim.fade_out, R.anim.fade_out)
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
override fun onBackPressed() = closeActivityWithReveal()
private fun ValidationMode.validationContent() = when (this) {
STATUS_CODE -> null
TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
JAVASCRIPT -> scriptInputLayout.getCode()
}
}

View file

@ -0,0 +1,43 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.addsite
import android.view.ViewAnimationUtils
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import com.afollestad.nocknock.utilities.ext.onEnd
import com.afollestad.nocknock.viewcomponents.ext.conceal
import com.afollestad.nocknock.viewcomponents.ext.show
import kotlinx.android.synthetic.main.activity_addsite.rootView
const val REVEAL_DURATION = 300L
internal fun AddSiteActivity.circularRevealActivity() {
val circularReveal =
ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius)
.apply {
duration = REVEAL_DURATION
interpolator = DecelerateInterpolator()
}
rootView.show()
circularReveal.start()
}
internal fun AddSiteActivity.closeActivityWithReveal() {
if (isClosing) return
isClosing = true
ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, revealRadius, 0f)
.apply {
duration = REVEAL_DURATION
interpolator = AccelerateInterpolator()
onEnd {
rootView.conceal()
finish()
overridePendingTransition(0, 0)
}
start()
}
}

View file

@ -0,0 +1,167 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.addsite
import androidx.annotation.CheckResult
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus.WAITING
import com.afollestad.nocknock.data.ValidationMode
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import javax.inject.Inject
/** @author Aidan Follestad (@afollestad) */
data class InputErrors(
var name: Int? = null,
var url: Int? = null,
var checkInterval: Int? = null,
var termSearch: Int? = null,
var javaScript: Int? = null
) {
@CheckResult fun any(): Boolean {
return name != null || url != null || checkInterval != null ||
termSearch != null || javaScript != null
}
}
/** @author Aidan Follestad (@afollestad) */
interface AddSitePresenter {
fun takeView(view: AddSiteView)
fun onUrlInputFocusChange(
focused: Boolean,
content: String
)
fun onValidationModeSelected(index: Int)
fun commit(
name: String,
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
)
fun dropView()
}
/** @author Aidan Follestad (@afollestad) */
class RealAddSitePresenter @Inject constructor(
private val serverModelStore: ServerModelStore,
private val checkStatusManager: CheckStatusManager
) : AddSitePresenter {
private var view: AddSiteView? = null
override fun takeView(view: AddSiteView) {
this.view = view
}
override fun onUrlInputFocusChange(
focused: Boolean,
content: String
) {
if (content.isEmpty() || focused) {
return
}
val url = HttpUrl.parse(content)
if (url == null ||
(url.scheme() != "http" &&
url.scheme() != "https")
) {
view?.showOrHideUrlSchemeWarning(true)
} else {
view?.showOrHideUrlSchemeWarning(false)
}
}
override fun onValidationModeSelected(index: Int) = with(view!!) {
showOrHideValidationSearchTerm(index == 1)
showOrHideScriptInput(index == 2)
setValidationModeDescription(
when (index) {
0 -> R.string.validation_mode_status_desc
1 -> R.string.validation_mode_term_desc
2 -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unknown validation mode position: $index")
}
)
}
override fun commit(
name: String,
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
) {
val inputErrors = InputErrors()
if (name.isEmpty()) {
inputErrors.name = R.string.please_enter_name
}
if (url.isEmpty()) {
inputErrors.url = R.string.please_enter_url
} else if (HttpUrl.parse(url) == null) {
inputErrors.url = R.string.please_enter_valid_url
}
if (checkInterval <= 0) {
inputErrors.checkInterval = R.string.please_enter_check_interval
}
if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) {
inputErrors.termSearch = R.string.please_enter_search_term
} else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) {
inputErrors.javaScript = R.string.please_enter_javaScript
}
if (inputErrors.any()) {
view?.setInputErrors(inputErrors)
return
}
val newModel = ServerModel(
name = name,
url = url,
status = WAITING,
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationContent
)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
val storedModel = async(IO) {
serverModelStore.put(newModel)
}.await()
checkStatusManager.scheduleCheck(
site = storedModel,
rightNow = true,
cancelPrevious = true
)
setDoneLoading()
onSiteAdded()
}
}
}
}
override fun dropView() {
view = null
}
}

View file

@ -0,0 +1,35 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.addsite
import androidx.annotation.StringRes
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
interface AddSiteView {
fun setLoading()
fun setDoneLoading()
fun showOrHideUrlSchemeWarning(show: Boolean)
fun showOrHideValidationSearchTerm(show: Boolean)
fun showOrHideScriptInput(show: Boolean)
fun setValidationModeDescription(@StringRes res: Int)
fun setInputErrors(errors: InputErrors)
fun onSiteAdded()
fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
)
}

View file

@ -1,187 +0,0 @@
/**
* 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 androidx.annotation.CheckResult
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.lifecycle.Lifecycle.Event.ON_START
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LiveData
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
import com.afollestad.nocknock.data.model.Status.WAITING
import com.afollestad.nocknock.data.model.ValidationMode
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.data.putSite
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.lang.System.currentTimeMillis
/** @author Aidan Follestad (@afollestad) */
class AddSiteViewModel(
private val database: AppDatabase,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
// Public properties
val name = MutableLiveData<String>()
val tags = MutableLiveData<String>()
val url = MutableLiveData<String>()
val timeout = MutableLiveData<Int>()
val validationMode = MutableLiveData<ValidationMode>()
val validationSearchTerm = MutableLiveData<String>()
val validationScript = MutableLiveData<String>()
val checkIntervalValue = MutableLiveData<Int>()
val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>()
val certificateUri = MutableLiveData<String>()
@OnLifecycleEvent(ON_START)
fun setDefaults() {
timeout.value = 10000
validationMode.value = STATUS_CODE
checkIntervalValue.value = 0
checkIntervalUnit.value = MINUTE
retryPolicyMinutes.value = 0
retryPolicyMinutes.value = 0
tags.value = ""
headers.value = emptyList()
}
private val isLoading = MutableLiveData<Boolean>()
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map {
val parsed = HttpUrl.parse(it)
return@map it.isNotEmpty() && parsed == null
}
}
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map {
when (it!!) {
STATUS_CODE -> R.string.validation_mode_status_desc
TERM_SEARCH -> R.string.validation_mode_term_desc
JAVASCRIPT -> R.string.validation_mode_javascript_desc
}
}
}
@CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
// Actions
fun commit(done: () -> Unit) {
scope.launch {
val newModel = generateDbModel() ?: return@launch
isLoading.value = true
val storedModel = withContext(ioDispatcher) {
database.putSite(newModel)
}
validationManager.scheduleValidation(
site = storedModel,
rightNow = true,
cancelPrevious = true
)
isLoading.value = false
done()
}
}
// Utilities
@VisibleForTesting(otherwise = PRIVATE)
fun getCheckIntervalMs(): Long {
val value = checkIntervalValue.value ?: return 0
val unit = checkIntervalUnit.value ?: return 0
return value * unit
}
@VisibleForTesting(otherwise = PRIVATE)
fun getValidationArgs(): String? {
return when (validationMode.value) {
TERM_SEARCH -> validationSearchTerm.value
JAVASCRIPT -> validationScript.value
else -> null
}
}
private fun generateDbModel(): Site? {
val timeout = timeout.value ?: 10_000
val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
val newSettings = SiteSettings(
validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!,
validationArgs = getValidationArgs(),
networkTimeout = timeout,
disabled = false,
certificate = certificateUri.value?.toString()
)
val newLastResult = ValidationResult(
timestampMs = currentTimeMillis(),
status = WAITING,
reason = null
)
val retryPolicyTimes = retryPolicyTimes.value ?: 0
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
RetryPolicy(
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
} else {
null
}
return Site(
id = 0,
name = name.value!!.trim(),
url = url.value!!.trim(),
tags = cleanedTags,
settings = newSettings,
lastResult = newLastResult,
retryPolicy = newRetryPolicy,
headers = headers.value ?: emptyList()
)
}
}

View file

@ -1,99 +0,0 @@
/**
* 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()
}

View file

@ -1,125 +1,78 @@
/**
/*
* Licensed under Apache-2.0
*
* 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.main
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.lifecycle.Observer
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
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.nocknock.R
import com.afollestad.nocknock.adapter.SiteAdapter
import com.afollestad.nocknock.adapter.TagAdapter
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.adapter.ServerAdapter
import com.afollestad.nocknock.data.ServerModel
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.viewcomponents.livedata.toViewVisibility
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list
import kotlinx.android.synthetic.main.activity_main.loadingProgress
import kotlinx.android.synthetic.main.include_app_bar.toolbar
import kotlinx.android.synthetic.main.activity_main.rootView
import kotlinx.android.synthetic.main.activity_main.toolbar
import kotlinx.android.synthetic.main.include_empty_view.emptyText
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlinx.android.synthetic.main.activity_main.tags_list as tagsList
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
class MainActivity : DarkModeSwitchActivity() {
class MainActivity : AppCompatActivity(), MainView {
private val notificationManager by inject<NockNotificationManager>()
private val intentProvider by inject<IntentProvider>()
internal val viewModel by viewModel<MainViewModel>()
private lateinit var siteAdapter: SiteAdapter
private lateinit var tagAdapter: TagAdapter
private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) {
viewModel.postSiteUpdate(it)
}
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) = presenter.onBroadcast(intent)
}
@Inject lateinit var presenter: MainPresenter
private lateinit var adapter: ServerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_main)
setupUi()
presenter.takeView(this)
notificationManager.createChannels()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
viewModel.onSites()
.observe(this, Observer { siteAdapter.set(it) })
viewModel.onEmptyTextVisibility()
.toViewVisibility(this, emptyText)
viewModel.onTags()
.observe(this, Observer { tagAdapter.set(it) })
viewModel.onTagsListVisibility()
.toViewVisibility(this, tagsList)
loadingProgress.observe(this, viewModel.onIsLoading())
processIntent(intent)
}
private fun setupUi() {
toolbar.run {
inflateMenu(R.menu.menu_main)
menu.findItem(R.id.dark_mode)
.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()
}
return@setOnMenuItemClickListener true
toolbar.inflateMenu(R.menu.menu_main)
toolbar.setOnMenuItemClickListener { item ->
if (item.itemId == R.id.about) {
AboutDialog.show(this)
}
return@setOnMenuItemClickListener true
}
siteAdapter = SiteAdapter(this::onSiteSelected)
list.run {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = siteAdapter
addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL))
}
adapter = ServerAdapter(this::onSiteSelected)
tagAdapter = TagAdapter(viewModel::onTagSelection)
tagsList.run {
layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false)
adapter = tagAdapter
}
list.layoutManager = LinearLayoutManager(this)
list.adapter = adapter
list.addItemDecoration(DividerItemDecoration(this, VERTICAL))
fab.setOnClickListener { addSite() }
processIntent(intent)
}
override fun onNewIntent(intent: Intent?) {
@ -127,8 +80,50 @@ class MainActivity : DarkModeSwitchActivity() {
intent?.let(::processIntent)
}
override fun onResume() {
super.onResume()
val filter = IntentFilter().apply {
addAction(ACTION_STATUS_UPDATE)
}
safeRegisterReceiver(intentReceiver, filter)
presenter.resume()
}
override fun onPause() {
super.onPause()
safeUnregisterReceiver(intentReceiver)
}
override fun onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun setModels(models: List<ServerModel>) {
list.post {
adapter.set(models)
emptyText.showOrHide(models.isEmpty())
}
}
override fun updateModel(model: ServerModel) {
list.post { adapter.update(model) }
}
override fun onSiteDeleted(model: ServerModel) {
list.post {
adapter.remove(model)
emptyText.showOrHide(adapter.itemCount == 0)
}
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
private fun onSiteSelected(
model: Site,
model: ServerModel,
longClick: Boolean
) {
if (longClick) {
@ -136,9 +131,8 @@ class MainActivity : DarkModeSwitchActivity() {
title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
0 -> viewModel.refreshSite(model)
1 -> addSiteForDuplication(model)
2 -> maybeRemoveSite(model)
0 -> presenter.refreshSite(model)
1 -> maybeRemoveSite(model)
}
}
}

View file

@ -1,73 +1,63 @@
/**
/*
* Licensed under Apache-2.0
*
* 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.main
import android.content.Intent
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.toHtml
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.viewsite.KEY_SITE
import com.afollestad.nocknock.ui.addsite.KEY_FAB_SIZE
import com.afollestad.nocknock.ui.addsite.KEY_FAB_X
import com.afollestad.nocknock.ui.addsite.KEY_FAB_Y
import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL
import kotlinx.android.synthetic.main.activity_main.fab
internal const val VIEW_SITE_RQ = 6923
internal const val ADD_SITE_RQ = 6969
// ADD
internal fun MainActivity.addSite() {
startActivityForResult(intentToAdd(), ADD_SITE_RQ)
startActivityForResult(intentToAdd(fab.x, fab.y, fab.measuredWidth), ADD_SITE_RQ)
}
internal fun MainActivity.addSiteForDuplication(site: Site) {
startActivityForResult(intentToAdd(site), ADD_SITE_RQ)
private fun MainActivity.intentToAdd(
x: Float,
y: Float,
size: Int
) = Intent(this, AddSiteActivity::class.java).apply {
putExtra(KEY_FAB_X, x)
putExtra(KEY_FAB_Y, y)
putExtra(KEY_FAB_SIZE, size)
addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
}
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) {
internal fun MainActivity.viewSite(model: ServerModel) {
startActivityForResult(intentToView(model), VIEW_SITE_RQ)
}
private fun MainActivity.intentToView(model: Site) =
private fun MainActivity.intentToView(model: ServerModel) =
Intent(this, ViewSiteActivity::class.java).apply {
putExtra(KEY_SITE, model)
putExtra(KEY_VIEW_MODEL, model)
}
// MISC
internal fun MainActivity.maybeRemoveSite(model: Site) {
internal fun MainActivity.maybeRemoveSite(model: ServerModel) {
MaterialDialog(this).show {
title(R.string.remove_site)
message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
positiveButton(R.string.remove) { viewModel.removeSite(model) }
positiveButton(R.string.remove) { presenter.removeSite(model) }
negativeButton(android.R.string.cancel)
}
}
internal fun MainActivity.processIntent(intent: Intent) {
if (intent.hasExtra(KEY_VIEW_NOTIFICATION_MODEL)) {
val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as Site
val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as ServerModel
viewSite(model)
}
}

View file

@ -0,0 +1,104 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.main
import android.content.Intent
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject
/** @author Aidan Follestad (@afollestad) */
interface MainPresenter {
fun takeView(view: MainView)
fun onBroadcast(intent: Intent)
fun resume()
fun refreshSite(site: ServerModel)
fun removeSite(site: ServerModel)
fun dropView()
}
/** @author Aidan Follestad (@afollestad) */
class RealMainPresenter @Inject constructor(
private val serverModelStore: ServerModelStore,
private val notificationManager: NockNotificationManager,
private val checkStatusManager: CheckStatusManager
) : MainPresenter {
private var view: MainView? = null
override fun takeView(view: MainView) {
this.view = view
notificationManager.createChannels()
ensureCheckJobs()
}
override fun onBroadcast(intent: Intent) {
if (intent.action == ACTION_STATUS_UPDATE) {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
view?.updateModel(model)
}
}
override fun resume() {
notificationManager.cancelStatusNotifications()
view!!.run {
setModels(listOf())
scopeWhileAttached(Main) {
launch(coroutineContext) {
val models = async(IO) {
serverModelStore.get()
}.await()
setModels(models)
}
}
}
}
override fun refreshSite(site: ServerModel) {
checkStatusManager.scheduleCheck(
site = site,
rightNow = true,
cancelPrevious = true
)
}
override fun removeSite(site: ServerModel) {
checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
view!!.scopeWhileAttached(Main) {
launch(coroutineContext) {
async(IO) { serverModelStore.delete(site) }.await()
view?.onSiteDeleted(site)
}
}
}
override fun dropView() {
view = null
}
private fun ensureCheckJobs() {
view!!.scopeWhileAttached(IO) {
launch(coroutineContext) {
checkStatusManager.ensureScheduledChecks()
}
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.main
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
interface MainView {
fun setModels(models: List<ServerModel>)
fun updateModel(model: ServerModel)
fun onSiteDeleted(model: ServerModel)
fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
)
}

View file

@ -1,157 +0,0 @@
/**
* 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.main
import androidx.annotation.CheckResult
import androidx.lifecycle.Lifecycle.Event.ON_RESUME
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.OnLifecycleEvent
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.ValidationExecutor
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.ScopedViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** @author Aidan Follestad (@afollestad) */
class MainViewModel(
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
private val sites = MutableLiveData<List<Site>>()
private val isLoading = MutableLiveData<Boolean>()
private val emptyTextVisibility = MutableLiveData<Boolean>()
private val tags = MutableLiveData<List<String>>()
private val tagsListVisibility = MutableLiveData<Boolean>()
@CheckResult fun onSites(): LiveData<List<Site>> = sites
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onEmptyTextVisibility(): LiveData<Boolean> = emptyTextVisibility
@CheckResult fun onTags(): LiveData<List<String>> = tags
@CheckResult fun onTagsListVisibility(): LiveData<Boolean> = tagsListVisibility
@OnLifecycleEvent(ON_RESUME)
fun onResume() = loadSites(emptyList())
fun onTagSelection(tags: List<String>) = loadSites(tags)
fun postSiteUpdate(model: Site) {
val currentSites = sites.value ?: return
val index = currentSites.indexOfFirst { it.id == model.id }
if (index == -1) return
sites.value = currentSites.toMutableList()
.apply {
this[index] = model
}
}
fun refreshSite(model: Site) {
validationManager.scheduleValidation(
site = model,
rightNow = true,
cancelPrevious = true
)
}
fun removeSite(model: Site) {
validationManager.cancelScheduledValidation(model)
notificationManager.cancelStatusNotification(model)
scope.launch {
isLoading.value = true
withContext(ioDispatcher) { database.deleteSite(model) }
val currentSites = sites.value ?: return@launch
val index = currentSites.indexOfFirst { it.id == model.id }
isLoading.value = false
if (index == -1) return@launch
val newSitesList = currentSites.toMutableList()
.apply {
removeAt(index)
}
sites.value = newSitesList
emptyTextVisibility.value = newSitesList.isEmpty()
}
}
private fun loadSites(forTags: List<String>) {
scope.launch {
notificationManager.cancelStatusNotifications()
emptyTextVisibility.value = false
isLoading.value = true
val unfiltered = withContext(ioDispatcher) {
database.allSites()
}
var result = unfiltered
if (forTags.isNotEmpty()) {
result = result.filter { site ->
val itemTags = site.tags.toLowerCase()
.split(",")
itemTags.any { tag -> forTags.contains(tag) }
}
}
sites.value = result
ensureCheckJobs()
isLoading.value = false
emptyTextVisibility.value = result.isEmpty()
val tagsValues = pullOutTags(unfiltered)
tags.value = tagsValues
tagsListVisibility.value = tagsValues.isNotEmpty()
}
}
private suspend fun ensureCheckJobs() {
withContext(ioDispatcher) {
validationManager.ensureScheduledValidations()
}
}
private fun pullOutTags(sites: List<Site>): List<String> {
return mutableListOf<String>().apply {
for (site in sites) {
val splitTags = site.tags.toLowerCase()
.split(',')
splitTags
.filter { it.isNotEmpty() }
.forEach { tag ->
if (!this.contains(tag)) {
this.add(tag)
}
}
}
sort()
}
}
}

View file

@ -1,184 +1,104 @@
/**
/*
* Licensed under Apache-2.0
*
* 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.viewsite
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.content.IntentFilter
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.lifecycle.Observer
import androidx.appcompat.app.AppCompatActivity
import com.afollestad.nocknock.R
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.data.LAST_CHECK_NONE
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ValidationMode
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.indexToValidationMode
import com.afollestad.nocknock.data.textRes
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
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 com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.headersLayout
import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
import kotlinx.android.synthetic.main.activity_viewsite.inputName
import kotlinx.android.synthetic.main.activity_viewsite.inputTags
import kotlinx.android.synthetic.main.activity_viewsite.inputUrl
import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress
import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_viewsite.rootView
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
import kotlinx.android.synthetic.main.activity_viewsite.toolbar
import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
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
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
class ViewSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
internal val viewModel by viewModel<ViewSiteViewModel>()
private lateinit var validationForm: Form
@Inject lateinit var presenter: ViewSitePresenter
private val intentProvider by inject<IntentProvider>()
private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) {
viewModel.setModel(it)
}
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) = presenter.onBroadcast(intent)
}
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_viewsite)
// 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())
// Status
viewModel.status.observe(this, Observer {
iconStatus.setStatus(it)
invalidateMenuForStatus(it)
})
// Name
inputName.attachLiveData(this, viewModel.name)
// Tags
inputTags.attachLiveData(this, viewModel.tags)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
// Validation mode
responseValidationMode.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationMode,
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
// Validation search term
responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// SSL certificate
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
viewModel.certificateUri.distinct()
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
// Headers
headersLayout.attach(viewModel.headers)
// Last/next check
viewModel.onLastCheckResultText()
.toViewText(this, textLastCheckResult)
viewModel.onNextCheckText()
.toViewText(this, textNextCheck)
}
private fun setupUi() {
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() }
actionView.setOnClickListener { presenter.checkNow() }
}
setOnMenuItemClickListener {
when (it.itemId) {
R.id.remove -> maybeRemoveSite()
R.id.disableChecks -> maybeDisableChecks()
}
true
maybeRemoveSite()
return@setOnMenuItemClickListener true
}
}
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
toolbar.elevation = if (it > toolbar.height / 4) {
toolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
inputUrl.setOnFocusChangeListener { _, hasFocus ->
presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
}
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
@ -187,105 +107,150 @@ class ViewSiteActivity : DarkModeSwitchActivity() {
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
// Disabled button
viewModel.onDisableChecksVisibility()
.observe(this, Observer {
toolbar.menu.findItem(R.id.disableChecks)
.isVisible = it
})
responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
// Done item text
viewModel.onDoneButtonText()
.observe(this, Observer {
toolbar.menu.findItem(R.id.commit)
.setTitle(it)
})
doneBtn.setOnClickListener {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
// 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() }
}
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationMode.validationContent()
)
}
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
visibility = viewModel.onValidationScriptVisibility(),
form = validationForm
)
disableChecksButton.setOnClickListener { maybeDisableChecks() }
// 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() ?: "")
}
presenter.takeView(this, intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent != null && intent.hasExtra(KEY_SITE)) {
val newModel = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(newModel)
presenter.onNewIntent(intent)
}
override fun onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun setLoading() = loadingProgress.setLoading()
override fun setDoneLoading() = loadingProgress.setDone()
override fun showOrHideUrlSchemeWarning(show: Boolean) {
textUrlWarning.showOrHide(show)
if (show) {
textUrlWarning.setText(R.string.warning_http_url)
}
}
override fun showOrHideValidationSearchTerm(show: Boolean) =
responseValidationSearchTerm.showOrHide(show)
override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show)
override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res)
override fun displayModel(model: ServerModel) = with(model) {
iconStatus.setStatus(this.status)
inputName.setText(this.name)
inputUrl.setText(this.url)
if (this.lastCheck == LAST_CHECK_NONE) {
textLastCheckResult.setText(R.string.none)
} else {
val statusText = this.status.textRes()
textLastCheckResult.text = if (statusText == 0) {
this.reason
} else {
getString(statusText)
}
}
if (this.disabled) {
textNextCheck.setText(R.string.auto_checks_disabled)
} else {
textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate()
}
checkIntervalLayout.set(this.checkInterval)
responseValidationMode.setSelection(validationMode.value - 1)
when (this.validationMode) {
TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "")
JAVASCRIPT -> scriptInputLayout.setCode(this.validationContent)
else -> {
responseValidationSearchTerm.setText("")
scriptInputLayout.clear()
}
}
disableChecksButton.showOrHide(!this.disabled)
doneBtn.setText(
if (this.disabled) R.string.renable_and_save_changes
else R.string.save_changes
)
invalidateMenuForStatus(model)
}
override fun setInputErrors(errors: InputErrors) {
inputName.error = if (errors.name != null) {
getString(errors.name!!)
} else {
null
}
inputUrl.error = if (errors.url != null) {
getString(errors.url!!)
} else {
null
}
checkIntervalLayout.setError(
if (errors.checkInterval != null) {
getString(errors.checkInterval!!)
} else {
null
}
)
responseValidationSearchTerm.error = if (errors.termSearch != null) {
getString(errors.termSearch!!)
} else {
null
}
scriptInputLayout.setError(
if (errors.javaScript != null) {
getString(errors.javaScript!!)
} else {
null
}
)
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
override fun onResume() {
super.onResume()
val filter = IntentFilter().apply {
addAction(ACTION_STATUS_UPDATE)
}
safeRegisterReceiver(intentReceiver, filter)
}
override fun onPause() {
super.onPause()
safeUnregisterReceiver(intentReceiver)
}
private fun ValidationMode.validationContent() = when (this) {
STATUS_CODE -> null
TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
JAVASCRIPT -> scriptInputLayout.getCode()
}
}

View file

@ -1,59 +1,46 @@
/**
/*
* Licensed under Apache-2.0
*
* 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.viewsite
import android.widget.ImageView
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.isPending
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.isPending
import com.afollestad.nocknock.toHtml
import com.afollestad.nocknock.utilities.ext.animateRotation
import kotlinx.android.synthetic.main.include_app_bar.toolbar
const val KEY_SITE = "site_model"
import kotlinx.android.synthetic.main.activity_viewsite.toolbar
internal fun ViewSiteActivity.maybeRemoveSite() {
val model = viewModel.site
val model = presenter.currentModel()
MaterialDialog(this).show {
title(R.string.remove_site)
message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
positiveButton(R.string.remove) {
viewModel.removeSite { finish() }
}
positiveButton(R.string.remove) { presenter.removeSite() }
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.maybeDisableChecks() {
val model = viewModel.site
val model = presenter.currentModel()
MaterialDialog(this).show {
title(R.string.disable_automatic_checks)
message(
text = context.getString(R.string.disable_automatic_checks_prompt, model.name).toHtml()
)
positiveButton(R.string.disable) { viewModel.disableSite() }
positiveButton(R.string.disable) { presenter.disableChecks() }
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) {
internal fun ViewSiteActivity.invalidateMenuForStatus(model: ServerModel) {
val refreshIcon = toolbar.menu.findItem(R.id.refresh)
.actionView as ImageView
if (status.isPending()) {
if (model.status.isPending()) {
refreshIcon.animateRotation()
} else {
refreshIcon.run {

View file

@ -0,0 +1,264 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.viewsite
import android.content.Intent
import androidx.annotation.CheckResult
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus.WAITING
import com.afollestad.nocknock.data.ValidationMode
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import org.jetbrains.annotations.TestOnly
import javax.inject.Inject
const val KEY_VIEW_MODEL = "site_model"
/** @author Aidan Follestad (@afollestad) */
data class InputErrors(
var name: Int? = null,
var url: Int? = null,
var checkInterval: Int? = null,
var termSearch: Int? = null,
var javaScript: Int? = null
) {
@CheckResult fun any(): Boolean {
return name != null || url != null || checkInterval != null ||
termSearch != null || javaScript != null
}
}
/** @author Aidan Follestad (@afollestad) */
interface ViewSitePresenter {
fun takeView(
view: ViewSiteView,
intent: Intent
)
fun onBroadcast(intent: Intent)
fun onNewIntent(intent: Intent?)
fun onUrlInputFocusChange(
focused: Boolean,
content: String
)
fun onValidationModeSelected(index: Int)
fun commit(
name: String,
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
)
fun checkNow()
fun disableChecks()
fun removeSite()
fun currentModel(): ServerModel
fun dropView()
}
/** @author Aidan Follestad (@afollestad) */
class RealViewSitePresenter @Inject constructor(
private val serverModelStore: ServerModelStore,
private val checkStatusManager: CheckStatusManager,
private val notificationManager: NockNotificationManager
) : ViewSitePresenter {
private var view: ViewSiteView? = null
private var currentModel: ServerModel? = null
override fun takeView(
view: ViewSiteView,
intent: Intent
) {
this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
this.view = view.apply {
displayModel(currentModel!!)
}
}
override fun onBroadcast(intent: Intent) {
if (intent.action == ACTION_STATUS_UPDATE) {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
this.currentModel = model
view?.displayModel(model)
}
}
override fun onNewIntent(intent: Intent?) {
if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) {
currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
view?.displayModel(currentModel!!)
}
}
override fun onUrlInputFocusChange(
focused: Boolean,
content: String
) {
if (content.isEmpty() || focused) {
return
}
val url = HttpUrl.parse(content)
if (url == null ||
(url.scheme() != "http" &&
url.scheme() != "https")
) {
view?.showOrHideUrlSchemeWarning(true)
} else {
view?.showOrHideUrlSchemeWarning(false)
}
}
override fun onValidationModeSelected(index: Int) = with(view!!) {
showOrHideValidationSearchTerm(index == 1)
showOrHideScriptInput(index == 2)
setValidationModeDescription(
when (index) {
0 -> R.string.validation_mode_status_desc
1 -> R.string.validation_mode_term_desc
2 -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unknown validation mode position: $index")
}
)
}
override fun commit(
name: String,
url: String,
checkInterval: Long,
validationMode: ValidationMode,
validationContent: String?
) {
val inputErrors = InputErrors()
if (name.isEmpty()) {
inputErrors.name = R.string.please_enter_name
}
if (url.isEmpty()) {
inputErrors.url = R.string.please_enter_url
} else if (HttpUrl.parse(url) == null) {
inputErrors.url = R.string.please_enter_valid_url
}
if (checkInterval <= 0) {
inputErrors.checkInterval = R.string.please_enter_check_interval
}
if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) {
inputErrors.termSearch = R.string.please_enter_search_term
} else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) {
inputErrors.javaScript = R.string.please_enter_javaScript
}
if (inputErrors.any()) {
view?.setInputErrors(inputErrors)
return
}
val newModel = currentModel!!.copy(
name = name,
url = url,
status = WAITING,
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationContent,
disabled = false
)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
async(IO) { serverModelStore.update(newModel) }.await()
checkStatusManager.scheduleCheck(
site = newModel,
rightNow = true,
cancelPrevious = true
)
setDoneLoading()
view?.finish()
}
}
}
}
override fun checkNow() = with(view!!) {
val checkModel = currentModel!!.copy(
status = WAITING
)
view?.displayModel(checkModel)
checkStatusManager.scheduleCheck(
site = checkModel,
rightNow = true,
cancelPrevious = true
)
}
override fun disableChecks() {
val site = currentModel!!
checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
currentModel = currentModel!!.copy(disabled = true)
async(IO) { serverModelStore.update(currentModel!!) }.await()
setDoneLoading()
view?.displayModel(currentModel!!)
}
}
}
}
override fun removeSite() {
val site = currentModel!!
checkStatusManager.cancelCheck(site)
notificationManager.cancelStatusNotification(site)
with(view!!) {
scopeWhileAttached(Main) {
launch(coroutineContext) {
setLoading()
async(IO) { serverModelStore.delete(site) }.await()
setDoneLoading()
view?.finish()
}
}
}
}
override fun currentModel() = this.currentModel!!
override fun dropView() {
view = null
currentModel = null
}
@TestOnly fun setModel(model: ServerModel) {
this.currentModel = model
}
}

View file

@ -0,0 +1,38 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui.viewsite
import androidx.annotation.StringRes
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import kotlin.coroutines.CoroutineContext
/** @author Aidan Follestad (@afollestad) */
interface ViewSiteView {
fun setLoading()
fun setDoneLoading()
fun displayModel(model: ServerModel)
fun showOrHideUrlSchemeWarning(show: Boolean)
fun showOrHideValidationSearchTerm(show: Boolean)
fun showOrHideScriptInput(show: Boolean)
fun setValidationModeDescription(@StringRes res: Int)
fun setInputErrors(errors: InputErrors)
fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
)
fun finish()
}

View file

@ -1,268 +0,0 @@
/**
* 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.viewsite
import androidx.annotation.CheckResult
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.AppDatabase
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
import com.afollestad.nocknock.data.model.ValidationMode
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.data.model.textRes
import com.afollestad.nocknock.data.updateSite
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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.lang.System.currentTimeMillis
/** @author Aidan Follestad (@afollestad) */
class ViewSiteViewModel(
private val stringProvider: StringProvider,
private val database: AppDatabase,
private val notificationManager: NockNotificationManager,
private val validationManager: ValidationExecutor,
mainDispatcher: CoroutineDispatcher,
private val ioDispatcher: CoroutineDispatcher
) : ScopedViewModel(mainDispatcher), LifecycleObserver {
lateinit var site: Site
// Public properties
val status = MutableLiveData<Status>()
val name = MutableLiveData<String>()
val tags = MutableLiveData<String>()
val url = MutableLiveData<String>()
val timeout = MutableLiveData<Int>()
val validationMode = MutableLiveData<ValidationMode>()
val validationSearchTerm = MutableLiveData<String>()
val validationScript = MutableLiveData<String>()
val checkIntervalValue = MutableLiveData<Int>()
val checkIntervalUnit = MutableLiveData<Long>()
val retryPolicyTimes = MutableLiveData<Int>()
val retryPolicyMinutes = MutableLiveData<Int>()
val headers = MutableLiveData<List<Header>>()
val certificateUri = MutableLiveData<String>()
internal val disabled = MutableLiveData<Boolean>()
internal val lastResult = MutableLiveData<ValidationResult?>()
private val isLoading = MutableLiveData<Boolean>()
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map {
val parsed = HttpUrl.parse(it)
return@map it.isNotEmpty() && parsed == null
}
}
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map {
when (it!!) {
STATUS_CODE -> R.string.validation_mode_status_desc
TERM_SEARCH -> R.string.validation_mode_term_desc
JAVASCRIPT -> R.string.validation_mode_javascript_desc
}
}
}
@CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
@CheckResult fun onDisableChecksVisibility(): LiveData<Boolean> = disabled.map { !it }
@CheckResult fun onDoneButtonText(): LiveData<Int> =
disabled.map {
if (it) R.string.renable_and_save_changes
else R.string.save_changes
}
@CheckResult fun onLastCheckResultText(): LiveData<String> = lastResult.map {
if (it == null) {
stringProvider.get(R.string.none)
} else {
val statusText = it.status.textRes()
if (statusText == 0) {
it.reason
} else {
stringProvider.get(statusText)
}
}
}
@CheckResult fun onNextCheckText(): LiveData<String> {
return zip(disabled, lastResult)
.map {
val disabled = it.first
val lastResult = it.second
if (disabled) {
stringProvider.get(R.string.auto_checks_disabled)
} else {
val lastCheck = lastResult?.timestampMs ?: currentTimeMillis()
(lastCheck + getCheckIntervalMs()).formatDate()
}
}
}
// Actions
fun commit(done: () -> Unit) {
scope.launch {
val updatedModel = getUpdatedDbModel() ?: return@launch
isLoading.value = true
withContext(ioDispatcher) {
database.updateSite(updatedModel)
}
validationManager.scheduleValidation(
site = updatedModel,
rightNow = true,
cancelPrevious = true
)
isLoading.value = false
done()
}
}
fun checkNow() {
val checkModel = site.withStatus(
status = WAITING
)
setModel(checkModel)
validationManager.scheduleValidation(
site = checkModel,
rightNow = true,
cancelPrevious = true
)
}
fun removeSite(done: () -> Unit) {
validationManager.cancelScheduledValidation(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
isLoading.value = true
withContext(ioDispatcher) {
database.deleteSite(site)
}
isLoading.value = false
done()
}
}
fun disableSite() {
validationManager.cancelScheduledValidation(site)
notificationManager.cancelStatusNotification(site)
scope.launch {
isLoading.value = true
val newModel = site.copy(
settings = site.settings!!.copy(
disabled = true
)
)
withContext(ioDispatcher) {
database.updateSite(newModel)
}
isLoading.value = false
setModel(newModel)
}
}
// Utilities
@VisibleForTesting(otherwise = PRIVATE)
fun getCheckIntervalMs(): Long {
val value = checkIntervalValue.value ?: return 0
val unit = checkIntervalUnit.value ?: return 0
return value * unit
}
@VisibleForTesting(otherwise = PRIVATE)
fun getValidationArgs(): String? {
return when (validationMode.value) {
TERM_SEARCH -> validationSearchTerm.value?.trim()
JAVASCRIPT -> validationScript.value?.trim()
else -> null
}
}
private fun getUpdatedDbModel(): Site? {
val timeout = timeout.value ?: 10_000
val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: ""
val newSettings = site.settings!!.copy(
validationIntervalMs = getCheckIntervalMs(),
validationMode = validationMode.value!!,
validationArgs = getValidationArgs(),
networkTimeout = timeout,
disabled = false,
certificate = certificateUri.value?.toString()
)
val retryPolicyTimes = retryPolicyTimes.value ?: 0
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
if (site.retryPolicy != null) {
// Have existing policy, update it
site.retryPolicy!!.copy(
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
} else {
// Create new policy
RetryPolicy(
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
}
} else {
// No policy
null
}
return site.copy(
name = name.value!!.trim(),
tags = cleanedTags,
url = url.value!!.trim(),
settings = newSettings,
retryPolicy = retryPolicy,
headers = headers.value ?: emptyList()
)
.withStatus(status = WAITING)
}
}

View file

@ -1,110 +0,0 @@
/**
* 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.viewsite
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.Status.WAITING
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 ViewSiteViewModel.setModel(site: Site) {
val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!")
this.site = site
status.value = site.lastResult?.status ?: WAITING
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
if (settings.certificate == "null") {
certificateUri.value = ""
} else {
certificateUri.value = settings.certificate
}
this.disabled.value = settings.disabled
this.lastResult.value = site.lastResult
}
private fun ViewSiteViewModel.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 ViewSiteViewModel.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()
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="true">
<alpha
android:duration="400"
android:fromAlpha="1.0"
android:interpolator="@android:anim/accelerate_interpolator"
android:toAlpha="0.0"/>
</set>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorAccent" android:state_pressed="false"/>
<item android:color="#FFFFFF" android:state_pressed="true"/>
</selector>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?colorAccent"/>
<stroke
android:color="@color/colorAccent_pressed"
android:width="1dp"/>
<corners android:radius="6dp"/>
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp"/>
</shape>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/colorAccent_pressed"/>
<stroke
android:color="?colorAccent"
android:width="1dp"/>
<corners android:radius="6dp"/>
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp"/>
</shape>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/checked_chip" android:state_pressed="false"/>
<item android:drawable="@drawable/checked_chip_pressed" android:state_pressed="true"/>
</selector>

View file

@ -1,4 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size android:height="1dp"/>
<solid android:color="?dividerColor"/>
<solid android:color="@color/dividerColor"/>
</shape>

View file

@ -4,6 +4,6 @@
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="?iconColor"
android:fillColor="#fff"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View file

@ -4,6 +4,6 @@
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="?iconColor"
android:fillColor="#fff"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View file

@ -4,6 +4,6 @@
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="?iconColor"
android:fillColor="#fff"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View file

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

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?android:windowBackground"/>
<stroke
android:color="?colorAccent"
android:width="1dp"/>
<corners android:radius="6dp"/>
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp"/>
</shape>

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/colorAccent_translucent"/>
<stroke
android:color="?colorAccent"
android:width="1dp"/>
<corners android:radius="6dp"/>
<padding
android:bottom="12dp"
android:left="12dp"
android:right="12dp"
android:top="12dp"/>
</shape>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/unchecked_chip" android:state_pressed="false"/>
<item android:drawable="@drawable/unchecked_chip_pressed" android:state_pressed="true"/>
</selector>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -10,13 +11,21 @@
android:id="@+id/rootView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?colorPrimary"
android:orientation="vertical"
>
<include layout="@layout/include_app_bar"/>
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/FlatToolbarTheme"
app:navigationIcon="@drawable/ic_action_close"
app:title="@string/add_site"
app:titleTextColor="#FFFFFF"
/>
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
@ -25,66 +34,66 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset_double"
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
>
<TextView
android:layout_marginTop="0dp"
android:text="@string/site_name"
style="@style/InputForm.Header"
/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/nameTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset"
>
<EditText
android:id="@+id/inputName"
android:hint="@string/site_name_hint"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:textColor="#FFFFFF"
style="@style/NockText.Body"
/>
<TextView
android:text="@string/site_url"
style="@style/InputForm.Header"
/>
</com.google.android.material.textfield.TextInputLayout>
<EditText
android:id="@+id/inputUrl"
android:hint="@string/site_url_hint"
android:inputType="textUri"
android:nextFocusDown="@+id/inputTags"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/urlTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_half"
>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_url"
android:inputType="textUri"
android:textColor="#FFFFFF"
style="@style/NockText.Body"
/>
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/textUrlWarning"
android:text="@string/warning_http_url"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:visibility="gone"
style="@style/InputForm.FieldNote"
/>
<TextView
android:text="@string/site_tags"
style="@style/InputForm.Header"
/>
<EditText
android:id="@+id/inputTags"
android:digits=",.?-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "
android:hint="@string/site_tags_hint"
android:inputType="text|textCapWords"
android:nextFocusDown="@+id/inputUrl"
tools:ignore="Autofill"
style="@style/InputForm.Field"
tools:text="Warning: this app checks for server availability with HTTP requests. It's recommended that you use an HTTP URL."
style="@style/NockText.Footnote"
/>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.interval.ValidationIntervalLayout
<com.afollestad.nocknock.viewcomponents.CheckIntervalLayout
android:id="@+id/checkIntervalLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -93,8 +102,11 @@
<TextView
android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_validation_mode"
style="@style/InputForm.Header"
style="@style/NockText.SectionHeader"
/>
<Spinner
@ -114,16 +126,16 @@
android:hint="@string/search_term"
android:visibility="gone"
tools:ignore="Autofill,TextFields"
style="@style/NockText.Body"
style="@style/NockText.Body.Light"
/>
<com.afollestad.nocknock.viewcomponents.js.JavaScriptInputLayout
<com.afollestad.nocknock.viewcomponents.JavaScriptInputLayout
android:id="@+id/scriptInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_half"
android:background="?scriptLayoutBackground"
android:background="@color/colorPrimaryDark"
/>
<TextView
@ -136,75 +148,13 @@
style="@style/NockText.Body.Light"
/>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
android:id="@+id/retryPolicyLayout"
<com.google.android.material.button.MaterialButton
android:id="@+id/doneBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_more"
/>
<TextView
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_timeout"
style="@style/InputForm.Header"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/ssl_certificate"
style="@style/NockText.SectionHeader"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
<EditText
android:id="@+id/sslCertificateInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:hint="@string/ssl_certificate_automatic"
android:inputType="textUri"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<Button
android:id="@+id/sslCertificateBrowse"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:text="@string/ssl_certificate_browse"
style="@style/AccentTextButton"
/>
</LinearLayout>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
android:id="@+id/headersLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
android:layout_marginTop="@dimen/content_inset_double"
android:text="@string/add_site"
style="@style/AccentButton"
/>
</LinearLayout>

View file

@ -15,19 +15,12 @@
android:orientation="vertical"
>
<include layout="@layout/include_app_bar"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tags_list"
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingBottom="@dimen/content_inset_half"
android:paddingEnd="@dimen/content_inset"
android:paddingStart="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
android:scrollbars="none"
android:visibility="gone"
android:theme="@style/MainToolbarTheme"
style="@style/MainToolbarStyle"
/>
<androidx.recyclerview.widget.RecyclerView
@ -41,31 +34,18 @@
<include layout="@layout/include_empty_view"/>
<com.google.android.material.button.MaterialButton
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_marginBottom="@dimen/content_inset"
android:layout_marginEnd="@dimen/content_inset_more"
android:minHeight="64dp"
android:paddingBottom="@dimen/content_inset_half"
android:paddingEnd="@dimen/content_inset"
android:paddingStart="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
android:text="@string/add_site"
app:cornerRadius="32dp"
app:icon="@drawable/ic_add"
app:iconTint="#fff"
style="@style/Widget.MaterialComponents.Button.Icon"
/>
<com.afollestad.nocknock.viewcomponents.LoadingIndicatorFrame
android:id="@+id/loadingProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
android:layout_margin="@dimen/content_inset"
android:src="@drawable/ic_add"
app:backgroundTint="?colorAccent"
app:elevation="@dimen/fab_elevation"
app:fabSize="normal"
app:pressedTranslationZ="@dimen/fab_elevation_pressed"
app:rippleColor="#40ffffff"
/>
</FrameLayout>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -14,7 +15,17 @@
android:orientation="vertical"
>
<include layout="@layout/include_app_bar"/>
<!-- Background is applied again here so programmatic elevation works -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorPrimary"
android:theme="@style/FlatToolbarTheme"
app:navigationIcon="@drawable/ic_action_close"
app:title="@string/view_site"
app:titleTextColor="?android:textColorPrimary"
/>
<ScrollView
android:id="@+id/scrollView"
@ -26,30 +37,15 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset_double"
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_less"
android:paddingTop="@dimen/content_inset_half"
>
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Header"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_quarter"
android:orientation="horizontal"
>
@ -70,14 +66,27 @@
android:orientation="vertical"
>
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:singleLine="true"
android:textColor="#FFFFFF"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
<EditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/site_url"
android:inputType="textUri"
android:nextFocusDown="@+id/inputTags"
android:singleLine="true"
android:textColor="#FFFFFF"
android:transitionName="site_url"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
@ -95,19 +104,6 @@
style="@style/NockText.Footnote"
/>
<EditText
android:id="@+id/inputTags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits=",.?-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "
android:hint="@string/site_tags_hint"
android:imeOptions="actionNext"
android:inputType="text|textCapWords"
android:singleLine="true"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
</LinearLayout>
</LinearLayout>
@ -119,13 +115,20 @@
android:layout_marginTop="@dimen/content_inset_less"
/>
<com.afollestad.nocknock.viewcomponents.interval.ValidationIntervalLayout
<com.afollestad.nocknock.viewcomponents.CheckIntervalLayout
android:id="@+id/checkIntervalLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
android:background="@color/dividerColorDark"
/>
<TextView
android:id="@+id/responseValidationLabel"
android:layout_width="wrap_content"
@ -150,18 +153,19 @@
android:layout_marginRight="-4dp"
android:layout_marginTop="-4dp"
android:hint="@string/search_term"
android:textColor="#FFFFFF"
android:visibility="gone"
tools:ignore="Autofill,TextFields"
style="@style/NockText.Body"
style="@style/NockText.Body.Light"
/>
<com.afollestad.nocknock.viewcomponents.js.JavaScriptInputLayout
<com.afollestad.nocknock.viewcomponents.JavaScriptInputLayout
android:id="@+id/scriptInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset"
android:layout_marginTop="@dimen/content_inset_half"
android:background="?scriptLayoutBackground"
android:background="@color/colorPrimaryDark"
/>
<TextView
@ -174,90 +178,6 @@
style="@style/NockText.Body.Light"
/>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
android:background="?dividerColor"
/>
<com.afollestad.nocknock.viewcomponents.retrypolicy.RetryPolicyLayout
android:id="@+id/retryPolicyLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/response_timeout"
style="@style/NockText.SectionHeader"
/>
<EditText
android:id="@+id/responseTimeoutInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="-4dp"
android:layout_marginStart="-4dp"
android:layout_marginTop="@dimen/content_inset_quarter"
android:hint="@string/response_timeout_default"
android:inputType="number"
android:maxLength="8"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/ssl_certificate"
style="@style/NockText.SectionHeader"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
<EditText
android:id="@+id/sslCertificateInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:hint="@string/ssl_certificate_automatic"
android:inputType="textUri"
tools:ignore="Autofill,HardcodedText,LabelFor"
style="@style/NockText.Body"
/>
<Button
android:id="@+id/sslCertificateBrowse"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:text="@string/ssl_certificate_browse"
style="@style/AccentTextButton"
/>
</LinearLayout>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.headers.HeaderStackLayout
android:id="@+id/headersLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
/>
<include layout="@layout/include_divider"/>
<TextView
@ -294,6 +214,24 @@
style="@style/NockText.Body"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/doneBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_double"
android:text="@string/save_changes"
style="@style/AccentButton"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/disableChecksButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_half"
android:text="@string/disable_automatic_checks"
style="@style/PrimaryDarkButton"
/>
</LinearLayout>
</ScrollView>

View file

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/app_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?colorPrimary"
android:elevation="0dp"
android:gravity="center"
tools:ignore="Overdraw"
>
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:elevation="0dp"
/>
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/toolbar_title"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:gravity="center"
android:text="@string/app_name"
android:textAppearance="@style/AppTheme.TextAppearance.Title"
/>
</FrameLayout>

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.AppCompatTextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/content_inset_half"
android:background="@drawable/unchecked_chip_selector"
android:textColor="?colorAccent"
app:textAllCaps="true"
tools:text="Testing"
style="@style/NockText.Body"
/>

View file

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

View file

@ -3,8 +3,4 @@
<item
android:id="@+id/about"
android:title="@string/about"/>
<item
android:id="@+id/dark_mode"
android:checkable="true"
android:title="@string/dark_mode"/>
</menu>

View file

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

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="AppThemeParent">
<item name="android:statusBarColor">@color/colorPrimary_lightTheme</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="AppThemeParent">
<item name="android:statusBarColor">@color/colorPrimary_lightTheme</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark_lightTheme</item>
<item name="android:windowLightNavigationBar">true</item>
</style>
<style name="AppTheme.Dark" parent="AppThemeParent.Dark">
<item name="android:statusBarColor">@color/colorPrimary_darkTheme</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark_darkTheme</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
</resources>

View file

@ -3,7 +3,6 @@
<string-array name="site_long_options" translatable="false">
<item>@string/refresh_status</item>
<item>@string/duplicate_and_modify</item>
<item>@string/remove_site</item>
</string-array>

View file

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr format="color" name="toolbarTitleColor"/>
<attr format="color" name="dividerColor"/>
<attr format="color" name="iconColor"/>
<attr format="color" name="scriptLayoutBackground"/>
</resources>

View file

@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary_lightTheme">#FFFFFF</color>
<color name="colorPrimaryDark_lightTheme">#F5F5F5</color>
<color name="colorPrimary_darkTheme">#212121</color>
<color name="colorPrimaryDark_darkTheme">#252525</color>
<color name="darkerGray">#303030</color>
<color name="lighterGray">#EEEEEE</color>
<color name="colorPrimary">#455A64</color>
<color name="colorPrimaryDark">#37474F</color>
<color name="colorAccent">#FF6E40</color>
<color name="colorAccent_pressed">#E44615</color>
<color name="colorAccent_translucent">#40FF6E40</color>
<color name="dividerColor">#EEEEEE</color>
</resources>

View file

@ -1,7 +1,10 @@
<resources>
<dimen name="empty_text_size">28sp</dimen>
<dimen name="list_text_spacing">6dp</dimen>
<dimen name="toolbar_elevation">4dp</dimen>
<dimen name="fab_elevation">4dp</dimen>
<dimen name="fab_elevation_pressed">8dp</dimen>
</resources>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#758F9A</color>
</resources>

View file

@ -1,90 +1,74 @@
<resources>
<string name="app_name">Nock Nock</string>
<string name="app_name_x">Nock Nock %1$s</string>
<string name="no_sites_added">No sites added!</string>
<string name="about">About</string>
<string name="about_body"><![CDATA[
A simple app designed by <b>Aidan Follestad</b>.<br/>
<a href=\'https://af.codes\'>Website</a>&nbsp;&nbsp;
<b>Nock Nock</b>, a simple app designed by <b>Aidan Follestad</b>.<br/>
<a href=\'https://aidanfollestad.com\'>Website</a>&nbsp;&nbsp;
<a href=\'https://twitter.com/afollestad\'>Twitter</a>&nbsp;&nbsp;
<a href=\'https://github.com/afollestad\'>GitHub</a>&nbsp;&nbsp;
<a href=\'https://www.linkedin.com/in/afollestad\'>LinkedIn</a>
<br/><br/><i>Nock Nock is open source! Check out the <a href=\'https://github.com/afollestad/nock-nock\'>GitHub page</a>!</i>
<br/>Icon by <a href=\'https://plus.google.com/+KevinAguilarC\'>Kevin Aguilar</a> of <b>221 Pixels</b>.
<br/>View the <a href=\'https://af.codes/privacypolicies/nocknock.html\'>Privacy Policy</a>.
]]></string>
<string name="dark_mode">Dark Mode</string>
]]></string>
<string name="dismiss">Dismiss</string>
<string name="add_site">Add Site</string>
<string name="site_name">Site Name</string>
<string name="site_name_hint">Site display name</string>
<string name="site_url">Site URL</string>
<string name="site_url_hint">https://yoursite.com</string>
<string name="site_tags">Site Tags</string>
<string name="site_tags_hint">e.g. One,Two,Three</string>
<string name="site_tags_hint_full">Tags (e.g. One,Two,Three)</string>
<string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</string>
<string name="please_enter_valid_url">Please enter a valid URL.</string>
<string name="please_enter_check_interval">Please input a check interval.</string>
<string name="please_enter_search_term">Please input a search term.</string>
<string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string>
<string name="please_enter_validCertUri">Certificate should be a valid file or content URI.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
<string name="options">Options</string>
<string name="already_checking_sites">Already checking sites!</string>
<string name="remove_site">Remove Site</string>
<string name="duplicate_and_modify">Duplicate and Modify</string>
<string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string>
<string name="remove">Remove</string>
<string name="save_changes">Save Changes</string>
<string name="view_site">View Site</string>
<string name="last_check_result">Last Validation Result</string>
<string name="next_check">Next Validation</string>
<string name="next_check_x">Next Validation: %1$s</string>
<string name="last_check_result">Last Check Result</string>
<string name="next_check">Next Check</string>
<string name="next_check_x">Next Check: %1$s</string>
<string name="now">Now</string>
<string name="none_turned_off">None (turned off)</string>
<string name="none">None</string>
<string name="disable_automatic_checks">Disable Automatic Validation</string>
<string name="disable_automatic_checks">Disable Automatic Checks</string>
<string name="disable_automatic_checks_prompt"><![CDATA[
Disable automatic validation for <b>%1$s</b>? The site will not be checked in the background
until you re-enable validation by tapping the checkmark (Save) icon. You can still manually
perform validation by tapping the Refresh icon at the top of this page.
Disable automatic checks for <b>%1$s</b>? The site will not be validated in the background
until you re-enable checks for it. You can still manually perform checks by tapping the
Refresh icon at the top of this page.
]]></string>
<string name="disable">Disable</string>
<string name="renable_and_save_changes">Enable Auto Validation &amp; Save Changes</string>
<string name="response_timeout">Network Response Timeout (ms)</string>
<string name="response_timeout_default">10000</string>
<string name="ssl_certificate">SSL Certificate</string>
<string name="ssl_certificate_automatic">(Automatic)</string>
<string name="ssl_certificate_browse">Browse</string>
<string name="renable_and_save_changes">Enable Checks &amp; Save Changes</string>
<string name="refresh_status">Refresh Status</string>
<string name="warning_http_url">
Warning: this app validates sites availability with HTTP requests. It\'s recommended that you
Warning: this app checks for server availability with HTTP requests. It\'s recommended that you
use an HTTP URL.
</string>
<string name="response_validation_mode">Response Validation Mode</string>
<string name="search_term">Search term…</string>
<string name="validation_mode_status_desc">
The HTTP status code is checked. If it\'s a successful status code, the site passes validation.
The HTTP status code is checked. If it\'s a successful status code, the site passes the check.
</string>
<string name="validation_mode_term_desc">
The status code check is done first. If it\'s successful, the response body is checked.
If it contains your search term, the site passes validation.
If it contains your search term, the site passes the check.
</string>
<string name="validation_mode_javascript_desc">
The status code check is done first. If it\'s successful, the response body is passed to the
JavaScript function above. If the function returns true, the site passes validation. Throw an
JavaScript function above. If the function returns true, the site passes the check. Throw an
exception to pass custom error messages to Nock Nock.
</string>
<string name="install_web_browser">Please install a web browser app, such as Google Chrome.</string>
</resources>

View file

@ -1,12 +1,73 @@
<resources>
<style name="AppTheme" parent="AppThemeParent"/>
<style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorPrimaryDark</item>
<style name="AppTheme.Dark" parent="AppThemeParent.Dark"/>
<item name="android:listDivider">@drawable/divider</item>
<item name="android:textColorPrimary">#212121</item>
<item name="android:textColorSecondary">#727272</item>
<item name="md_corner_radius">16dp</item>
<item name="md_font_title">@font/lato_black</item>
<item name="md_font_body">@font/lato</item>
<item name="md_font_button">@font/lato_bold</item>
</style>
<style name="AppTheme.Ink" parent="Theme.MaterialComponents.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorPrimaryDark</item>
<item name="android:listDivider">@drawable/divider</item>
<item name="md_corner_radius">16dp</item>
<item name="md_font_title">@font/lato_black</item>
<item name="md_font_body">@font/lato</item>
<item name="md_font_button">@font/lato_bold</item>
</style>
<style name="AppTheme.Transparent" parent="AppTheme.Ink">
<item name="android:windowIsTranslucent">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
<style name="MainToolbarTheme" parent="@style/Theme.MaterialComponents">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:fontFamily">@font/lato_black</item>
</style>
<style name="MainToolbarStyle" parent="@style/Widget.MaterialComponents.Toolbar">
<item name="android:background">?colorPrimary</item>
<item name="android:elevation">@dimen/default_elevation</item>
<item name="title">@string/app_name</item>
<item name="titleTextColor">#FFFFFF</item>
<item name="popupTheme">@style/Theme.MaterialComponents.Light.DarkActionBar</item>
</style>
<style name="FlatToolbarTheme" parent="@style/Theme.MaterialComponents">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:fontFamily">@font/lato_black</item>
</style>
<style name="AccentButton" parent="Widget.MaterialComponents.Button">
<item name="android:textColor">#fff</item>
<item name="backgroundTint">@color/colorAccent</item>
<item name="android:fontFamily">@font/lato</item>
</style>
<style name="PrimaryDarkButton" parent="Widget.MaterialComponents.Button">
<item name="android:textColor">#fff</item>
<item name="backgroundTint">@color/darkerGray</item>
<item name="backgroundTint">@color/colorPrimaryDark</item>
<item name="android:fontFamily">@font/lato</item>
</style>

View file

@ -1,54 +0,0 @@
<resources>
<style name="AppThemeParent" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="colorPrimary">@color/colorPrimary_lightTheme</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark_lightTheme</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorAccent</item>
<item name="toolbarTitleColor">#000000</item>
<item name="dividerColor">#EEEEEE</item>
<item name="iconColor">#000000</item>
<item name="scriptLayoutBackground">@color/lighterGray</item>
<item name="android:textColorPrimary">#212121</item>
<item name="android:textColorSecondary">#727272</item>
<item name="android:windowBackground">@color/colorPrimary_lightTheme</item>
<item name="android:listDivider">@drawable/divider</item>
<item name="android:statusBarColor">#E5E5E5</item>
<item name="md_corner_radius">16dp</item>
<item name="md_font_title">@font/lato_bold</item>
<item name="md_font_body">@font/lato</item>
<item name="md_font_button">@font/lato_bold</item>
</style>
<style name="AppThemeParent.Dark" parent="Theme.MaterialComponents.NoActionBar">
<item name="colorPrimary">@color/colorPrimary_darkTheme</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark_darkTheme</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorAccent</item>
<item name="toolbarTitleColor">#ffffff</item>
<item name="dividerColor">#303030</item>
<item name="iconColor">#FFFFFF</item>
<item name="scriptLayoutBackground">@color/darkerGray</item>
<item name="android:textColorPrimary">#FFFFFF</item>
<item name="android:textColorSecondary">#F0F0F0</item>
<item name="android:windowBackground">@color/colorPrimary_darkTheme</item>
<item name="android:listDivider">@drawable/divider</item>
<item name="android:statusBarColor">@color/colorPrimary_darkTheme</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark_darkTheme</item>
<item name="md_corner_radius">16dp</item>
<item name="md_font_title">@font/lato_bold</item>
<item name="md_font_body">@font/lato</item>
<item name="md_font_button">@font/lato_bold</item>
</style>
</resources>

View file

@ -1,33 +0,0 @@
<resources>
<style name="AppTheme.TextAppearance.Title" parent="TextAppearance.MaterialComponents.Headline6">
<item name="fontFamily">@font/lato_bold</item>
<item name="android:fontFamily">@font/lato_bold</item>
<item name="android:textColor">?toolbarTitleColor</item>
</style>
<style name="InputForm"/>
<style name="InputForm.Header" parent="NockText.SectionHeader">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/content_inset_less</item>
</style>
<style name="InputForm.Field" parent="NockText.Body">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/content_inset_quarter</item>
<item name="android:singleLine">true</item>
<item name="android:imeOptions">actionNext</item>
<item name="android:layout_marginStart">-4dp</item>
<item name="android:layout_marginEnd">-4dp</item>
</style>
<style name="InputForm.FieldNote" parent="NockText.Footnote">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginTop">@dimen/list_text_spacing</item>
</style>
</resources>

View file

@ -0,0 +1,241 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.ui.addsite.AddSiteView
import com.afollestad.nocknock.ui.addsite.InputErrors
import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class AddSitePresenterTest {
private val serverModelStore = mock<ServerModelStore> {
on { runBlocking { put(any()) } } doAnswer { inv ->
inv.getArgument<ServerModel>(0)
}
}
private val checkStatusManager = mock<CheckStatusManager>()
private val view = mock<AddSiteView>()
private val presenter = RealAddSitePresenter(
serverModelStore,
checkStatusManager
)
@Before fun setup() {
doAnswer {
val exec = it.getArgument<ScopeReceiver>(1)
runBlocking { exec() }
Unit
}.whenever(view)
.scopeWhileAttached(any(), any())
presenter.takeView(view)
}
@After fun destroy() {
presenter.dropView()
}
@Test fun onUrlInputFocusChange_focused() {
presenter.onUrlInputFocusChange(true, "hello")
verifyNoMoreInteractions(view)
}
@Test fun onUrlInputFocusChange_empty() {
presenter.onUrlInputFocusChange(false, "")
verifyNoMoreInteractions(view)
}
@Test fun onUrlInputFocusChange_notHttpHttps() {
presenter.onUrlInputFocusChange(false, "ftp://hello.com")
verify(view).showOrHideUrlSchemeWarning(true)
}
@Test fun onUrlInputFocusChange_isHttpOrHttps() {
presenter.onUrlInputFocusChange(false, "http://hello.com")
presenter.onUrlInputFocusChange(false, "https://hello.com")
verify(view, times(2)).showOrHideUrlSchemeWarning(false)
}
@Test fun onValidationModeSelected_statusCode() {
presenter.onValidationModeSelected(0)
verify(view).showOrHideValidationSearchTerm(false)
verify(view).showOrHideScriptInput(false)
verify(view).setValidationModeDescription(R.string.validation_mode_status_desc)
}
@Test fun onValidationModeSelected_termSearch() {
presenter.onValidationModeSelected(1)
verify(view).showOrHideValidationSearchTerm(true)
verify(view).showOrHideScriptInput(false)
verify(view).setValidationModeDescription(R.string.validation_mode_term_desc)
}
@Test fun onValidationModeSelected_javaScript() {
presenter.onValidationModeSelected(2)
verify(view).showOrHideValidationSearchTerm(false)
verify(view).showOrHideScriptInput(true)
verify(view).setValidationModeDescription(R.string.validation_mode_javascript_desc)
}
@Test(expected = IllegalStateException::class)
fun onValidationModeSelected_other() {
presenter.onValidationModeSelected(3)
}
@Test fun commit_nameError() {
presenter.commit(
"",
"https://test.com",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.name).isEqualTo(R.string.please_enter_name)
}
@Test fun commit_urlEmptyError() {
presenter.commit(
"Testing",
"",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.url).isEqualTo(R.string.please_enter_url)
}
@Test fun commit_urlFormatError() {
presenter.commit(
"Testing",
"ftp://hello.com",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.url).isEqualTo(R.string.please_enter_valid_url)
}
@Test fun commit_checkIntervalError() {
presenter.commit(
"Testing",
"https://hello.com",
-1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.checkInterval).isEqualTo(R.string.please_enter_check_interval)
}
@Test fun commit_termSearchError() {
presenter.commit(
"Testing",
"https://hello.com",
1,
TERM_SEARCH,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.termSearch).isEqualTo(R.string.please_enter_search_term)
}
@Test fun commit_javaScript_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
JAVASCRIPT,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.javaScript).isEqualTo(R.string.please_enter_javaScript)
}
@Test fun commit_success() = runBlocking {
presenter.commit(
"Testing",
"https://hello.com",
1,
STATUS_CODE,
null
)
val modelCaptor = argumentCaptor<ServerModel>()
verify(view).setLoading()
verify(serverModelStore).put(modelCaptor.capture())
val model = modelCaptor.firstValue
verify(view, never()).setInputErrors(any())
verify(checkStatusManager).scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true,
fromFinishingJob = false
)
verify(view).setDoneLoading()
verify(view).onSiteAdded()
}
}

View file

@ -0,0 +1,119 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock
import android.content.Intent
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.main.MainView
import com.afollestad.nocknock.ui.main.RealMainPresenter
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class MainPresenterTest {
private val serverModelStore = mock<ServerModelStore>()
private val notificationManager = mock<NockNotificationManager>()
private val checkStatusManager = mock<CheckStatusManager>()
private val view = mock<MainView>()
private val presenter = RealMainPresenter(
serverModelStore,
notificationManager,
checkStatusManager
)
@Before fun setup() {
doAnswer {
val exec = it.getArgument<ScopeReceiver>(1)
runBlocking { exec() }
Unit
}.whenever(view)
.scopeWhileAttached(any(), any())
presenter.takeView(view)
}
@After fun destroy() {
presenter.dropView()
}
@Test fun onBroadcast() {
val badIntent = fakeIntent("Hello World")
presenter.onBroadcast(badIntent)
val model = fakeModel()
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
.doReturn(model)
presenter.onBroadcast(goodIntent)
verify(view, times(1)).updateModel(model)
}
@Test fun resume() = runBlocking {
val model = fakeModel()
whenever(serverModelStore.get()).doReturn(listOf(model))
presenter.resume()
verify(notificationManager).cancelStatusNotifications()
val modelsCaptor = argumentCaptor<List<ServerModel>>()
verify(view, times(2)).setModels(modelsCaptor.capture())
assertThat(modelsCaptor.firstValue).isEmpty()
assertThat(modelsCaptor.lastValue.single()).isEqualTo(model)
}
@Test fun refreshSite() {
val model = fakeModel()
presenter.refreshSite(model)
verify(checkStatusManager).scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true
)
}
@Test fun removeSite() = runBlocking {
val model = fakeModel()
presenter.removeSite(model)
verify(checkStatusManager).cancelCheck(model)
verify(notificationManager).cancelStatusNotification(model)
verify(serverModelStore).delete(model)
verify(view).onSiteDeleted(model)
}
private fun fakeModel() = ServerModel(
name = "Test",
url = "https://test.com",
validationMode = STATUS_CODE
)
private fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
}

View file

@ -1,216 +0,0 @@
/**
* 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
import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.HeaderDao
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.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.utilities.providers.CanNotifyModel
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.isA
import com.nhaarman.mockitokotlin2.mock
import java.lang.System.currentTimeMillis
fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
fun fakeSettingsModel(
id: Long,
validationMode: ValidationMode = STATUS_CODE
) = SiteSettings(
siteId = id,
validationIntervalMs = 600000,
validationMode = validationMode,
validationArgs = null,
disabled = false,
networkTimeout = 10000,
certificate = null
)
fun fakeResultModel(
id: Long,
status: Status = OK,
reason: String? = null
) = ValidationResult(
siteId = id,
status = status,
reason = reason,
timestampMs = currentTimeMillis()
)
fun fakeRetryPolicy(
id: Long,
count: Int = 3,
minutes: Int = 6
) = RetryPolicy(
siteId = id,
count = count,
minutes = minutes
)
fun fakeHeaders(siteId: Long): List<Header> {
return listOf(
Header(id = siteId + 1, siteId = siteId, key = "Content-Type", value = "text/html"),
Header(id = siteId + 2, siteId = siteId, key = "User-Agent", value = "NockNock")
)
}
fun fakeModel(
id: Long,
tags: String = ""
) = Site(
id = id,
name = "Test",
url = "https://test.com",
tags = tags,
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id),
headers = fakeHeaders(id)
)
val MOCK_MODEL_1 = fakeModel(1, tags = "one,two")
val MOCK_MODEL_2 = fakeModel(2, tags = "three,four")
val MOCK_MODEL_3 = fakeModel(3, tags = "five,six")
val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3)
fun mockDatabase(): AppDatabase {
val siteDao = mock<SiteDao> {
on { insert(isA()) } doReturn 1
on { one(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1)
2L -> listOf(MOCK_MODEL_2)
3L -> listOf(MOCK_MODEL_3)
else -> listOf()
}
}
on { all() } doReturn ALL_MOCK_MODELS
on { update(isA()) } doAnswer { inv ->
return@doAnswer inv.arguments.size
}
on { delete(isA()) } doAnswer { inv ->
return@doAnswer inv.arguments.size
}
}
val settingsDao = mock<SiteSettingsDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.settings!!)
2L -> listOf(MOCK_MODEL_2.settings!!)
3L -> listOf(MOCK_MODEL_3.settings!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val resultsDao = mock<ValidationResultsDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.lastResult!!)
2L -> listOf(MOCK_MODEL_2.lastResult!!)
3L -> listOf(MOCK_MODEL_3.lastResult!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val retryDao = mock<RetryPolicyDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.retryPolicy!!)
2L -> listOf(MOCK_MODEL_2.retryPolicy!!)
3L -> listOf(MOCK_MODEL_3.retryPolicy!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val headerDao = mock<HeaderDao> {
on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> MOCK_MODEL_1.headers
2L -> MOCK_MODEL_2.headers
3L -> MOCK_MODEL_3.headers
else -> listOf()
}
}
on { insert(isA<Header>()) } doReturn 1L
on { insert(isA<List<Header>>()) } doReturn listOf(1L, 2L)
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
return mock {
on { siteDao() } doReturn siteDao
on { siteSettingsDao() } doReturn settingsDao
on { validationResultsDao() } doReturn resultsDao
on { retryPolicyDao() } doReturn retryDao
on { headerDao() } doReturn headerDao
}
}
fun mockIntentProvider() = object : IntentProvider {
override fun createFilter(vararg actions: String): IntentFilter {
return mock {
on { this.getAction(any()) } doAnswer { inv ->
val index = inv.getArgument<Int>(0)
return@doAnswer actions[index]
}
on { this.actionsIterator() } doReturn actions.iterator()
on { this.countActions() } doReturn actions.size
}
}
override fun getPendingIntentForViewSite(model: CanNotifyModel): PendingIntent {
// basically no-op right now
return mock()
}
}

View file

@ -0,0 +1,361 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock
import android.content.Intent
import com.afollestad.nocknock.data.LAST_CHECK_NONE
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus.WAITING
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.viewsite.InputErrors
import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL
import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter
import com.afollestad.nocknock.ui.viewsite.ViewSiteView
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
import com.nhaarman.mockitokotlin2.whenever
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
class ViewSitePresenterTest {
private val serverModelStore = mock<ServerModelStore> {
on { runBlocking { put(any()) } } doAnswer { inv ->
inv.getArgument<ServerModel>(0)
}
}
private val checkStatusManager = mock<CheckStatusManager>()
private val notificationManager = mock<NockNotificationManager>()
private val view = mock<ViewSiteView>()
private val presenter = RealViewSitePresenter(
serverModelStore,
checkStatusManager,
notificationManager
)
@Before fun setup() {
doAnswer {
val exec = it.getArgument<ScopeReceiver>(1)
runBlocking { exec() }
Unit
}.whenever(view)
.scopeWhileAttached(any(), any())
val model = fakeModel().copy(lastCheck = 0)
val intent = fakeIntent("")
whenever(intent.getSerializableExtra(KEY_VIEW_MODEL))
.doReturn(model)
presenter.takeView(view, intent)
assertThat(presenter.currentModel()).isEqualTo(model)
verify(view, times(1)).displayModel(model)
}
@After fun destroy() {
presenter.dropView()
}
@Test fun onBroadcast() {
val badIntent = fakeIntent("Hello World")
presenter.onBroadcast(badIntent)
val model = fakeModel()
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
.doReturn(model)
presenter.onBroadcast(goodIntent)
assertThat(presenter.currentModel()).isEqualTo(model)
verify(view, times(1)).displayModel(model)
}
@Test fun onNewIntent() {
val badIntent = fakeIntent(ACTION_STATUS_UPDATE)
presenter.onBroadcast(badIntent)
val model = fakeModel()
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_VIEW_MODEL))
.doReturn(model)
presenter.onBroadcast(goodIntent)
verify(view, times(1)).displayModel(model)
}
@Test fun onUrlInputFocusChange_focused() {
presenter.onUrlInputFocusChange(true, "hello")
verifyNoMoreInteractions(view)
}
@Test fun onUrlInputFocusChange_empty() {
presenter.onUrlInputFocusChange(false, "")
verifyNoMoreInteractions(view)
}
@Test fun onUrlInputFocusChange_notHttpHttps() {
presenter.onUrlInputFocusChange(false, "ftp://hello.com")
verify(view).showOrHideUrlSchemeWarning(true)
}
@Test fun onUrlInputFocusChange_isHttpOrHttps() {
presenter.onUrlInputFocusChange(false, "http://hello.com")
presenter.onUrlInputFocusChange(false, "https://hello.com")
verify(view, times(2)).showOrHideUrlSchemeWarning(false)
}
@Test fun onValidationModeSelected_statusCode() {
presenter.onValidationModeSelected(0)
verify(view).showOrHideValidationSearchTerm(false)
verify(view).showOrHideScriptInput(false)
verify(view).setValidationModeDescription(R.string.validation_mode_status_desc)
}
@Test fun onValidationModeSelected_termSearch() {
presenter.onValidationModeSelected(1)
verify(view).showOrHideValidationSearchTerm(true)
verify(view).showOrHideScriptInput(false)
verify(view).setValidationModeDescription(R.string.validation_mode_term_desc)
}
@Test fun onValidationModeSelected_javaScript() {
presenter.onValidationModeSelected(2)
verify(view).showOrHideValidationSearchTerm(false)
verify(view).showOrHideScriptInput(true)
verify(view).setValidationModeDescription(R.string.validation_mode_javascript_desc)
}
@Test(expected = IllegalStateException::class)
fun onValidationModeSelected_other() {
presenter.onValidationModeSelected(3)
}
@Test fun commit_nameError() {
presenter.commit(
"",
"https://test.com",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.name).isEqualTo(R.string.please_enter_name)
}
@Test fun commit_urlEmptyError() {
presenter.commit(
"Testing",
"",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.url).isEqualTo(R.string.please_enter_url)
}
@Test fun commit_urlFormatError() {
presenter.commit(
"Testing",
"ftp://hello.com",
1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.url).isEqualTo(R.string.please_enter_valid_url)
}
@Test fun commit_checkIntervalError() {
presenter.commit(
"Testing",
"https://hello.com",
-1,
STATUS_CODE,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.checkInterval).isEqualTo(R.string.please_enter_check_interval)
}
@Test fun commit_termSearchError() {
presenter.commit(
"Testing",
"https://hello.com",
1,
TERM_SEARCH,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.termSearch).isEqualTo(R.string.please_enter_search_term)
}
@Test fun commit_javaScript_error() {
presenter.commit(
"Testing",
"https://hello.com",
1,
JAVASCRIPT,
null
)
val inputErrorsCaptor = argumentCaptor<InputErrors>()
verify(view).setInputErrors(inputErrorsCaptor.capture())
verify(checkStatusManager, never())
.scheduleCheck(any(), any(), any(), any())
val errors = inputErrorsCaptor.firstValue
assertThat(errors.javaScript).isEqualTo(R.string.please_enter_javaScript)
}
@Test fun commit_success() = runBlocking {
val name = "Testing"
val url = "https://hello.com"
val checkInterval = 60000L
val validationMode = TERM_SEARCH
val validationContent = "Hello World"
val disabledModel = presenter.currentModel()
.copy(disabled = true)
presenter.setModel(disabledModel)
presenter.commit(
name,
url,
checkInterval,
validationMode,
validationContent
)
val modelCaptor = argumentCaptor<ServerModel>()
verify(view).setLoading()
verify(serverModelStore).update(modelCaptor.capture())
val model = modelCaptor.firstValue
assertThat(model.name).isEqualTo(name)
assertThat(model.url).isEqualTo(url)
assertThat(model.checkInterval).isEqualTo(checkInterval)
assertThat(model.validationMode).isEqualTo(validationMode)
assertThat(model.validationContent).isEqualTo(validationContent)
assertThat(model.disabled).isFalse()
verify(view, never()).setInputErrors(any())
verify(checkStatusManager).scheduleCheck(
site = model,
rightNow = true,
cancelPrevious = true,
fromFinishingJob = false
)
verify(view).setDoneLoading()
verify(view).finish()
}
@Test fun checkNow() {
val newModel = presenter.currentModel()
.copy(
status = WAITING
)
presenter.checkNow()
verify(view, never()).setLoading()
verify(view).displayModel(newModel)
verify(checkStatusManager).scheduleCheck(
site = newModel,
rightNow = true,
cancelPrevious = true
)
}
@Test fun disableChecks() = runBlocking {
val model = presenter.currentModel()
presenter.disableChecks()
verify(checkStatusManager).cancelCheck(model)
verify(notificationManager).cancelStatusNotification(model)
verify(view).setLoading()
val modelCaptor = argumentCaptor<ServerModel>()
verify(serverModelStore).update(modelCaptor.capture())
val newModel = modelCaptor.firstValue
assertThat(newModel.disabled).isTrue()
assertThat(newModel.lastCheck).isEqualTo(LAST_CHECK_NONE)
verify(view).setDoneLoading()
verify(view, times(1)).displayModel(newModel)
}
@Test fun removeSite() = runBlocking {
val model = presenter.currentModel()
presenter.removeSite()
verify(checkStatusManager).cancelCheck(model)
verify(notificationManager).cancelStatusNotification(model)
verify(view).setLoading()
verify(serverModelStore).delete(model)
verify(view).setDoneLoading()
verify(view).finish()
}
private fun fakeModel() = ServerModel(
id = 1,
name = "Test",
url = "https://test.com",
validationMode = STATUS_CODE
)
private fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
}

View file

@ -1,73 +0,0 @@
/**
* 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.broadcasts
import android.app.Application
import android.content.IntentFilter
import com.afollestad.nocknock.MOCK_MODEL_2
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.fakeIntent
import com.afollestad.nocknock.mockIntentProvider
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.argumentCaptor
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import org.junit.Test
/** @author Aidan Follestad (@afollestad) */
class StatusUpdateIntentReceiverTest {
private val app = mock<Application>()
private val intentProvider = mockIntentProvider()
private val callback = mock<SiteCallback>()
private val receiver = StatusUpdateIntentReceiver(app, intentProvider, callback)
@Test fun onReceive() {
val badIntent = fakeIntent("Hello World")
receiver.intentReceiver.onReceive(app, badIntent)
val goodIntent = fakeIntent(ACTION_STATUS_UPDATE)
whenever(goodIntent.getSerializableExtra(KEY_UPDATE_MODEL))
.doReturn(MOCK_MODEL_2)
receiver.intentReceiver.onReceive(app, goodIntent)
verify(callback, times(1)).invoke(MOCK_MODEL_2)
}
@Test fun onResume() {
receiver.onResume()
val filterCaptor = argumentCaptor<IntentFilter>()
verify(app).registerReceiver(eq(receiver.intentReceiver), filterCaptor.capture())
val actionIterator = filterCaptor.firstValue.actionsIterator()
assertThat(actionIterator.hasNext()).isTrue()
val filterAction = actionIterator.next()
assertThat(filterAction).isEqualTo(ACTION_STATUS_UPDATE)
assertThat(actionIterator.hasNext()).isFalse()
}
@Test fun onPause() {
receiver.onPause()
verify(app).unregisterReceiver(receiver.intentReceiver)
}
}

Some files were not shown because too many files have changed in this diff Show more