Compare commits

..

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

263 changed files with 2957 additions and 11380 deletions

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 +0,0 @@
### Guidelines
1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`.
2. A PR should be focused and contained. If you are changing multiple unrelated things, they should be in separate PRs.
3. A PR should fix a bug or solve a problem - something that only you would use is not necessarily something that should be published.
4. Give your PR a detailed title and description - look over your code one last time before actually creating the PR. Give it a self-review.
**If you do not follow the guidelines, your PR will be rejected.**

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

1
.idea/.name generated Normal file
View file

@ -0,0 +1 @@
nock-nock

22
.idea/compiler.xml generated Normal file
View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<resourceExtensions />
<wildcardResourcePatterns>
<entry name="!?*.java" />
<entry name="!?*.form" />
<entry name="!?*.class" />
<entry name="!?*.groovy" />
<entry name="!?*.scala" />
<entry name="!?*.flex" />
<entry name="!?*.kt" />
<entry name="!?*.clj" />
<entry name="!?*.aj" />
</wildcardResourcePatterns>
<annotationProcessing>
<profile default="true" name="Default" enabled="false">
<processorPath useClasspath="true" />
</profile>
</annotationProcessing>
</component>
</project>

3
.idea/copyright/profiles_settings.xml generated Normal file
View file

@ -0,0 +1,3 @@
<component name="CopyrightManager">
<settings default="" />
</component>

6
.idea/encodings.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="PROJECT" charset="UTF-8" />
</component>
</project>

39
.idea/misc.xml generated
View file

@ -1,49 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CMakeSettings">
<configurations>
<configuration PROFILE_NAME="Debug" CONFIG_NAME="Debug" />
</configurations>
<component name="EntryPointsManager">
<entry_points version="2.0" />
</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="4">
<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" />
<item index="3" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" />
<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" />
<item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" />
<item index="3" class="java.lang.String" itemvalue="android.support.annotation.Nullable" />
</list>
</value>
</option>
<option name="myNotNulls">
<value>
<list size="9">
<list size="4">
<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="ProjectLevelVcsManager" settingsEditedManually="false">
<OptionsSetting value="true" id="Add" />
<OptionsSetting value="true" id="Remove" />
<OptionsSetting value="true" id="Checkout" />
<OptionsSetting value="true" id="Update" />
<OptionsSetting value="true" id="Status" />
<OptionsSetting value="true" id="Edit" />
<ConfirmationsSetting value="0" id="Add" />
<ConfirmationsSetting value="0" id="Remove" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" assert-keyword="true" jdk-15="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
</project>

7
.idea/modules.xml generated
View file

@ -3,12 +3,7 @@
<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$/viewcomponents/viewcomponents.iml" filepath="$PROJECT_DIR$/viewcomponents/viewcomponents.iml" />
</modules>
</component>
</project>
</project>

22
.travis.yml Normal file
View file

@ -0,0 +1,22 @@
language: android
jdk: oraclejdk8
android:
components:
- tools
- platform-tools
- build-tools-24.0.1
- android-24
- 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:
- '.+'

BIN
NockNock-0.1.3.0.apk Normal file

Binary file not shown.

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

@ -1,79 +1,43 @@
apply from: '../dependencies.gradle'
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion versions.compileSdk
buildToolsVersion versions.buildTools
compileSdkVersion 24
buildToolsVersion "24.0.1"
defaultConfig {
applicationId "com.afollestad.nocknock"
minSdkVersion versions.minSdk
targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode
versionName versions.publishVersion
}
defaultConfig {
applicationId "com.afollestad.nocknock"
minSdkVersion 21
targetSdkVersion 24
versionCode 13
versionName "0.1.3.0"
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
lintOptions {
abortOnError false
}
jackOptions {
enabled true
}
}
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation project(':common')
implementation project(':engine')
implementation project(':data')
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
// 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
// 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'
compile 'com.android.support:appcompat-v7:24.2.0'
compile 'com.android.support:design:24.2.0'
compile 'com.afollestad.material-dialogs:core:0.9.0.1'
compile 'com.afollestad.material-dialogs:commons:0.9.0.1'
compile 'com.afollestad:bridge:3.2.5'
compile 'com.afollestad:inquiry:3.2.1'
compile files('libs/rhino-1.7.7.1.jar')
}

BIN
app/libs/rhino-1.7.7.1.jar Normal file

Binary file not shown.

17
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,17 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in C:\Users\drumm\AppData\Local\Android\sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

View file

@ -3,57 +3,60 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.afollestad.nocknock">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".NockNockApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="AllowBackup,GoogleAppIndexingWarning">
<activity
android:name="com.afollestad.nocknock.ui.main.MainActivity"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<activity
android:name="com.afollestad.nocknock.ui.MainActivity"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="com.afollestad.nocknock.ui.addsite.AddSiteActivity"
android:label="@string/add_site"
android:launchMode="singleTop"
android:windowSoftInputMode="stateHidden"/>
<activity
android:name="com.afollestad.nocknock.ui.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:windowSoftInputMode="stateHidden"/>
<activity
android:name="com.afollestad.nocknock.ui.ViewSiteActivity"
android:label="@string/view_site"
android:launchMode="singleTop"
android:theme="@style/AppTheme.Ink"
android:windowSoftInputMode="stateHidden" />
<service
android:name=".engine.validation.ValidationJob"
android:label="@string/check_service_name"
android:permission="android.permission.BIND_JOB_SERVICE"/>
<service
android:name="com.afollestad.nocknock.services.CheckService"
android:enabled="true"
android:label="Site Check Service" />
<receiver android:name=".engine.validation.BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<receiver android:name=".receivers.BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<meta-data
android:name="preloaded_fonts"
android:resource="@array/preloaded_fonts"/>
<receiver android:name=".receivers.ConnectivityReceiver">
<intent-filter>
<action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
<action android:name="android.net.wifi.WIFI_STATE_CHANGED" />
</intent-filter>
</receiver>
</application>
</application>
</manifest>
</manifest>

View file

@ -1,92 +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.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
/** @author Aidan Follestad (@afollestad) */
fun Application.onActivityLifeChange(cb: ActivityLifeChange) {
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivitySaveInstanceState(
activity: Activity?,
outState: Bundle?
) = Unit
override fun onActivityPaused(activity: Activity) = cb(activity, false)
override fun onActivityResumed(activity: Activity) = cb(activity, true)
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivityCreated(
activity: Activity?,
savedInstanceState: Bundle?
) = Unit
})
}
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,79 +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.
*/
@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 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
/** @author Aidan Follestad (@afollestad) */
class NockNockApp : Application() {
private var resumedActivities: Int = 0
override fun onCreate() {
super.onCreate()
if (DEBUG) {
Timber.plant(DebugTree())
}
Timber.plant(FabricTree())
Fabric.with(this, Crashlytics())
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++
log("Activity resumed: $activity, resumedActivities = $resumedActivities")
} else {
resumedActivities--
log("Activity paused: $activity, resumedActivities = $resumedActivities")
}
check(resumedActivities >= 0) { "resumedActivities can't go below 0." }
nockNotificationManager.setIsAppOpen(resumedActivities > 0)
}
}
}

View file

@ -0,0 +1,171 @@
package com.afollestad.nocknock.adapter;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.api.ServerModel;
import com.afollestad.nocknock.api.ServerStatus;
import com.afollestad.nocknock.util.TimeUtil;
import com.afollestad.nocknock.views.StatusImageView;
import java.util.ArrayList;
import java.util.Collections;
/**
* @author Aidan Follestad (afollestad)
*/
public class ServerAdapter extends RecyclerView.Adapter<ServerAdapter.ServerVH> {
private final Object LOCK = new Object();
private ArrayList<ServerModel> mServers;
private ClickListener mListener;
public interface ClickListener {
void onSiteSelected(int index, ServerModel model, boolean longClick);
}
public void performClick(int index, boolean longClick) {
if (mListener != null) {
mListener.onSiteSelected(index, mServers.get(index), longClick);
}
}
public ServerAdapter(ClickListener listener) {
mListener = listener;
mServers = new ArrayList<>(2);
}
public void add(ServerModel model) {
mServers.add(model);
notifyItemInserted(mServers.size() - 1);
}
public void update(int index, ServerModel model) {
mServers.set(index, model);
notifyItemChanged(index);
}
public void update(ServerModel model) {
synchronized (LOCK) {
for (int i = 0; i < mServers.size(); i++) {
if (mServers.get(i).id == model.id) {
update(i, model);
break;
}
}
}
}
public void remove(int index) {
mServers.remove(index);
notifyItemRemoved(index);
}
public void remove(ServerModel model) {
synchronized (LOCK) {
for (int i = 0; i < mServers.size(); i++) {
if (mServers.get(i).id == model.id) {
remove(i);
break;
}
}
}
}
public void set(ServerModel[] models) {
if (models == null || models.length == 0) {
mServers.clear();
return;
}
mServers = new ArrayList<>(models.length);
Collections.addAll(mServers, models);
notifyDataSetChanged();
}
public void clear() {
mServers.clear();
notifyDataSetChanged();
}
@Override
public ServerAdapter.ServerVH onCreateViewHolder(ViewGroup parent, int viewType) {
final View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_server, parent, false);
return new ServerVH(v, this);
}
@Override
public void onBindViewHolder(ServerAdapter.ServerVH holder, int position) {
final ServerModel model = mServers.get(position);
holder.textName.setText(model.name);
holder.textUrl.setText(model.url);
holder.iconStatus.setStatus(model.status);
switch (model.status) {
case ServerStatus.OK:
holder.textStatus.setText(R.string.everything_checks_out);
break;
case ServerStatus.WAITING:
holder.textStatus.setText(R.string.waiting);
break;
case ServerStatus.CHECKING:
holder.textStatus.setText(R.string.checking_status);
break;
case ServerStatus.ERROR:
holder.textStatus.setText(model.reason);
break;
}
if (model.checkInterval <= 0) {
holder.textInterval.setText("");
} else {
final long now = System.currentTimeMillis();
final long nextCheck = model.lastCheck + model.checkInterval;
final long difference = nextCheck - now;
holder.textInterval.setText(TimeUtil.str(difference));
}
}
@Override
public int getItemCount() {
return mServers.size();
}
public static class ServerVH extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener {
final StatusImageView iconStatus;
final TextView textName;
final TextView textInterval;
final TextView textUrl;
final TextView textStatus;
final ServerAdapter adapter;
public ServerVH(View itemView, ServerAdapter adapter) {
super(itemView);
iconStatus = (StatusImageView) itemView.findViewById(R.id.iconStatus);
textName = (TextView) itemView.findViewById(R.id.textName);
textInterval = (TextView) itemView.findViewById(R.id.textInterval);
textUrl = (TextView) itemView.findViewById(R.id.textUrl);
textStatus = (TextView) itemView.findViewById(R.id.textStatus);
this.adapter = adapter;
itemView.setOnClickListener(this);
itemView.setOnLongClickListener(this);
}
@Override
public void onClick(View view) {
adapter.performClick(getAdapterPosition(), false);
}
@Override
public boolean onLongClick(View view) {
adapter.performClick(getAdapterPosition(), true);
return false;
}
}
}

View file

@ -1,131 +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.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.utilities.ui.onDebouncedClick
import kotlinx.android.synthetic.main.list_item_server.view.iconStatus
import kotlinx.android.synthetic.main.list_item_server.view.textInterval
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
/** @author Aidan Follestad (@afollestad) */
class SiteViewHolder constructor(
itemView: View,
private val adapter: SiteAdapter
) : RecyclerView.ViewHolder(itemView), View.OnLongClickListener {
init {
itemView.onDebouncedClick {
adapter.performClick(adapterPosition, false)
}
itemView.setOnLongClickListener(this)
}
fun bind(model: Site) {
requireNotNull(model.settings) { "Settings must be populated." }
itemView.textName.text = model.name
itemView.textUrl.text = model.url
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)
}
} else {
itemView.iconStatus.setStatus(WAITING)
itemView.textStatus.setText(R.string.none)
}
val res = itemView.resources
when {
model.settings?.disabled == true -> {
itemView.textInterval.setText(R.string.checks_disabled)
}
model.lastResult?.status.isPending() -> {
itemView.textInterval.text = res.getString(
R.string.next_check_x,
res.getString(R.string.now)
)
}
else -> {
itemView.textInterval.text = res.getString(
R.string.next_check_x,
model.intervalText()
)
}
}
}
override fun onLongClick(view: View): Boolean {
adapter.performClick(adapterPosition, true)
return false
}
}
/** @author Aidan Follestad (@afollestad) */
class SiteAdapter(private val listener: Listener) : RecyclerView.Adapter<SiteViewHolder>() {
private var models = mutableListOf<Site>()
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)
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): SiteViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_server, parent, false)
return SiteViewHolder(v, this)
}
override fun onBindViewHolder(
holder: SiteViewHolder,
position: Int
) {
val model = models[position]
holder.bind(model)
}
override fun getItemCount() = models.size
}

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

@ -0,0 +1,36 @@
package com.afollestad.nocknock.api;
import com.afollestad.inquiry.annotations.Column;
import java.io.Serializable;
/**
* @author Aidan Follestad (afollestad)
*/
public class ServerModel implements Serializable {
public ServerModel() {
}
@Column(name = "_id", primaryKey = true, notNull = true, autoIncrement = true)
public long id;
@Column
public String name;
@Column
public String url;
@Column
@ServerStatus.Enum
public int status;
@Column
public long checkInterval;
@Column
public long lastCheck;
@Column
public String reason;
@Column
@ValidationMode.Enum
public int validationMode;
@Column
public String validationContent;
}

View file

@ -0,0 +1,21 @@
package com.afollestad.nocknock.api;
import android.support.annotation.IntDef;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* @author Aidan Follestad (afollestad)
*/
public final class ServerStatus {
public final static int OK = 1;
public final static int WAITING = 2;
public final static int CHECKING = 3;
public final static int ERROR = 4;
@Retention(RetentionPolicy.SOURCE)
@IntDef({OK, WAITING, CHECKING, ERROR})
public @interface Enum {}
}

View file

@ -0,0 +1,21 @@
package com.afollestad.nocknock.api;
import android.support.annotation.IntDef;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* @author Aidan Follestad (afollestad)
*/
public final class ValidationMode {
public final static int STATUS_CODE = 1;
public final static int TERM_SEARCH = 2;
public final static int JAVASCRIPT = 3;
@Retention(RetentionPolicy.SOURCE)
@IntDef({STATUS_CODE, TERM_SEARCH, JAVASCRIPT})
public @interface Enum {
}
}

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,33 @@
package com.afollestad.nocknock.dialogs;
import android.app.Dialog;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v4.app.DialogFragment;
import android.support.v7.app.AppCompatActivity;
import android.text.Html;
import com.afollestad.materialdialogs.MaterialDialog;
import com.afollestad.nocknock.R;
/**
* @author Aidan Follestad (afollestad)
*/
public class AboutDialog extends DialogFragment {
public static void show(AppCompatActivity context) {
AboutDialog dialog = new AboutDialog();
dialog.show(context.getSupportFragmentManager(), "[ABOUT_DIALOG]");
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new MaterialDialog.Builder(getActivity())
.title(R.string.about)
.positiveText(R.string.dismiss)
.content(Html.fromHtml(getString(R.string.about_body)))
.contentLineSpacing(1.6f)
.build();
}
}

View file

@ -1,44 +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.dialogs
import android.app.Dialog
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) */
class AboutDialog : DialogFragment() {
companion object {
private const val TAG = "[ABOUT_DIALOG]"
fun show(context: AppCompatActivity) {
val dialog = AboutDialog()
dialog.show(context.supportFragmentManager, TAG)
}
}
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))
.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

@ -0,0 +1,28 @@
package com.afollestad.nocknock.receivers;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import com.afollestad.inquiry.Inquiry;
import com.afollestad.nocknock.api.ServerModel;
import com.afollestad.nocknock.ui.MainActivity;
import com.afollestad.nocknock.util.AlarmUtil;
/**
* @author Aidan Follestad (afollestad)
*/
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals("android.intent.action.BOOT_COMPLETED")) {
final Inquiry inq = Inquiry.newInstance(context, MainActivity.DB_NAME).build(false);
ServerModel[] models = inq
.selectFrom(MainActivity.SITES_TABLE_NAME, ServerModel.class)
.all();
AlarmUtil.setSiteChecks(context, models);
inq.destroyInstance();
}
}
}

View file

@ -0,0 +1,25 @@
package com.afollestad.nocknock.receivers;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import com.afollestad.nocknock.services.CheckService;
import com.afollestad.nocknock.util.NetworkUtil;
/**
* @author Aidan Follestad (afollestad)
*/
public class ConnectivityReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
final boolean hasInternet = NetworkUtil.hasInternet(context);
Log.v("ConnectivityReceiver", "Connectivity state changed... has internet? " + hasInternet);
if (hasInternet) {
context.startService(new Intent(context, CheckService.class)
.putExtra(CheckService.ONLY_WAITING, true));
}
}
}

View file

@ -0,0 +1,241 @@
package com.afollestad.nocknock.services;
import android.app.IntentService;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.graphics.BitmapFactory;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.util.Log;
import android.widget.Toast;
import com.afollestad.bridge.Bridge;
import com.afollestad.bridge.BridgeException;
import com.afollestad.bridge.Response;
import com.afollestad.inquiry.Inquiry;
import com.afollestad.inquiry.Query;
import com.afollestad.nocknock.BuildConfig;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.api.ServerModel;
import com.afollestad.nocknock.api.ServerStatus;
import com.afollestad.nocknock.api.ValidationMode;
import com.afollestad.nocknock.ui.MainActivity;
import com.afollestad.nocknock.ui.ViewSiteActivity;
import com.afollestad.nocknock.util.JsUtil;
import com.afollestad.nocknock.util.NetworkUtil;
import java.util.Locale;
/**
* @author Aidan Follestad (afollestad)
*/
@SuppressWarnings("CheckResult")
public class CheckService extends IntentService {
public static String ACTION_CHECK_UPDATE = BuildConfig.APPLICATION_ID + ".CHECK_UPDATE";
public static String ACTION_RUNNING = BuildConfig.APPLICATION_ID + ".CHECK_RUNNING";
public static String MODEL_ID = "model_id";
public static String ONLY_WAITING = "only_waiting";
public static int NOTI_ID = 3456;
public CheckService() {
super("NockNockCheckService");
}
private static void LOG(String msg, Object... format) {
if (format != null)
msg = String.format(Locale.getDefault(), msg, format);
Log.v("NockNockService", msg);
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
protected void onHandleIntent(Intent intent) {
Inquiry.newInstance(this, MainActivity.DB_NAME).build();
isRunning(true);
Bridge.config()
.defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)");
final Query<ServerModel, Integer> query = Inquiry.get(this)
.selectFrom(MainActivity.SITES_TABLE_NAME, ServerModel.class);
if (intent != null && intent.hasExtra(MODEL_ID)) {
query.where("_id = ?", intent.getLongExtra(MODEL_ID, -1));
} else if (intent != null && intent.getBooleanExtra(ONLY_WAITING, false)) {
query.where("status = ?", ServerStatus.WAITING);
}
final ServerModel[] sites = query.all();
if (sites == null || sites.length == 0) {
LOG("No sites added to check, service will terminate.");
isRunning(false);
stopSelf();
return;
}
LOG("Checking %d sites...", sites.length);
sendBroadcast(new Intent(ACTION_RUNNING));
for (ServerModel site : sites) {
LOG("Updating %s (%s) status to WAITING...", site.name, site.url);
site.status = ServerStatus.WAITING;
updateStatus(site);
}
if (NetworkUtil.hasInternet(this)) {
for (ServerModel site : sites) {
LOG("Checking %s (%s)...", site.name, site.url);
site.status = ServerStatus.CHECKING;
site.lastCheck = System.currentTimeMillis();
updateStatus(site);
try {
final Response response = Bridge.get(site.url)
.throwIfNotSuccess()
.cancellable(false)
.request()
.response();
site.reason = null;
site.status = ServerStatus.OK;
if (site.validationMode == ValidationMode.TERM_SEARCH) {
final String body = response.asString();
if (body == null || !body.contains(site.validationContent)) {
site.status = ServerStatus.ERROR;
site.reason = "Term \"" + site.validationContent + "\" not found in response body.";
}
} else if (site.validationMode == ValidationMode.JAVASCRIPT) {
final String body = response.asString();
site.reason = JsUtil.exec(site.validationContent, body);
if (site.reason != null && !site.toString().isEmpty())
site.status = ServerStatus.ERROR;
}
if (site.status == ServerStatus.ERROR)
showNotification(this, site);
} catch (BridgeException e) {
processError(e, site);
}
updateStatus(site);
}
} else {
LOG("No internet connection, waiting.");
}
isRunning(false);
LOG("Service is finished!");
}
private void processError(BridgeException e, ServerModel site) {
site.status = ServerStatus.OK;
site.reason = null;
switch (e.reason()) {
case BridgeException.REASON_REQUEST_CANCELLED:
// Shouldn't happen
break;
case BridgeException.REASON_REQUEST_FAILED:
case BridgeException.REASON_RESPONSE_UNPARSEABLE:
case BridgeException.REASON_RESPONSE_UNSUCCESSFUL:
case BridgeException.REASON_RESPONSE_IOERROR:
//noinspection ConstantConditions
if (e.response() != null && e.response().code() == 401) {
// Don't consider 401 unsuccessful here
site.reason = null;
} else {
site.status = ServerStatus.ERROR;
site.reason = e.getMessage();
}
break;
case BridgeException.REASON_REQUEST_TIMEOUT:
site.status = ServerStatus.ERROR;
site.reason = getString(R.string.timeout);
break;
case BridgeException.REASON_RESPONSE_VALIDATOR_ERROR:
case BridgeException.REASON_RESPONSE_VALIDATOR_FALSE:
// Not used
break;
}
if (site.status != ServerStatus.OK) {
LOG("%s error: %s", site.name, site.reason);
showNotification(this, site);
}
}
private void updateStatus(ServerModel site) {
Inquiry.get(this)
.update(MainActivity.SITES_TABLE_NAME, ServerModel.class)
.where("_id = ?", site.id)
.values(site)
.run();
sendBroadcast(new Intent(ACTION_CHECK_UPDATE)
.putExtra("model", site));
}
private void isRunning(boolean running) {
PreferenceManager.getDefaultSharedPreferences(this)
.edit().putBoolean("check_service_running", running).commit();
}
public static boolean isRunning(Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean("check_service_running", false);
}
public static void isAppOpen(Context context, boolean open) {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean("is_app_open", open).commit();
}
public static boolean isAppOpen(Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean("is_app_open", false);
}
private static void showNotification(Context context, ServerModel site) {
if (isAppOpen(context)) {
// Don't show notifications while the app is open
return;
}
final NotificationManagerCompat nm = NotificationManagerCompat.from(context);
final PendingIntent openIntent = PendingIntent.getActivity(context, 9669,
new Intent(context, ViewSiteActivity.class)
.putExtra("model", site)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_CANCEL_CURRENT);
final Notification noti = new NotificationCompat.Builder(context)
.setContentTitle(site.name)
.setContentText(context.getString(R.string.something_wrong))
.setContentIntent(openIntent)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher))
.setPriority(Notification.PRIORITY_HIGH)
.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_VIBRATE)
.build();
nm.notify(site.url, NOTI_ID, noti);
}
@Override
public void onDestroy() {
try {
Inquiry.destroy(this);
} catch (Throwable t2) {
t2.printStackTrace();
}
super.onDestroy();
}
}

View file

@ -0,0 +1,255 @@
package com.afollestad.nocknock.ui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.design.widget.TextInputLayout;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.util.Patterns;
import android.view.View;
import android.view.ViewAnimationUtils;
import android.view.ViewTreeObserver;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.api.ServerModel;
import com.afollestad.nocknock.api.ServerStatus;
import com.afollestad.nocknock.api.ValidationMode;
/**
* @author Aidan Follestad (afollestad)
*/
public class AddSiteActivity extends AppCompatActivity implements View.OnClickListener {
private View rootLayout;
private Toolbar toolbar;
private TextInputLayout nameTiLayout;
private EditText inputName;
private TextInputLayout urlTiLayout;
private EditText inputUrl;
private EditText inputInterval;
private Spinner spinnerInterval;
private TextView textUrlWarning;
private Spinner responseValidationSpinner;
private boolean isClosing;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_addsite);
rootLayout = findViewById(R.id.rootView);
nameTiLayout = (TextInputLayout) findViewById(R.id.nameTiLayout);
inputName = (EditText) findViewById(R.id.inputName);
urlTiLayout = (TextInputLayout) findViewById(R.id.urlTiLayout);
inputUrl = (EditText) findViewById(R.id.inputUrl);
textUrlWarning = (TextView) findViewById(R.id.textUrlWarning);
inputInterval = (EditText) findViewById(R.id.checkIntervalInput);
spinnerInterval = (Spinner) findViewById(R.id.checkIntervalSpinner);
responseValidationSpinner = (Spinner) findViewById(R.id.responseValidationMode);
toolbar = (Toolbar) findViewById(R.id.toolbar);
toolbar.setNavigationOnClickListener(view -> closeActivityWithReveal());
if (savedInstanceState == null) {
rootLayout.setVisibility(View.INVISIBLE);
ViewTreeObserver viewTreeObserver = rootLayout.getViewTreeObserver();
if (viewTreeObserver.isAlive()) {
viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
circularRevealActivity();
rootLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
}
}
ArrayAdapter<String> intervalOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner,
getResources().getStringArray(R.array.interval_options));
intervalOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown);
spinnerInterval.setAdapter(intervalOptionsAdapter);
inputUrl.setOnFocusChangeListener((view, hasFocus) -> {
if (!hasFocus) {
final String inputStr = inputUrl.getText().toString().trim();
if (inputStr.isEmpty()) return;
final Uri uri = Uri.parse(inputStr);
if (uri.getScheme() == null) {
inputUrl.setText("http://" + inputStr);
textUrlWarning.setVisibility(View.GONE);
} else if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
textUrlWarning.setVisibility(View.VISIBLE);
textUrlWarning.setText(R.string.warning_http_url);
} else {
textUrlWarning.setVisibility(View.GONE);
}
}
});
ArrayAdapter<String> validationOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner,
getResources().getStringArray(R.array.response_validation_options));
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown);
responseValidationSpinner.setAdapter(validationOptionsAdapter);
responseValidationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
final View searchTerm = findViewById(R.id.responseValidationSearchTerm);
final View javascript = findViewById(R.id.responseValidationScript);
final TextView modeDesc = (TextView) findViewById(R.id.validationModeDescription);
searchTerm.setVisibility(i == 1 ? View.VISIBLE : View.GONE);
javascript.setVisibility(i == 2 ? View.VISIBLE : View.GONE);
switch (i) {
case 0:
modeDesc.setText(R.string.validation_mode_status_desc);
break;
case 1:
modeDesc.setText(R.string.validation_mode_term_desc);
break;
case 2:
modeDesc.setText(R.string.validation_mode_javascript_desc);
break;
}
}
@Override
public void onNothingSelected(AdapterView<?> adapterView) {
}
});
findViewById(R.id.doneBtn).setOnClickListener(this);
}
@Override
public void onBackPressed() {
closeActivityWithReveal();
}
private void closeActivityWithReveal() {
if (isClosing) return;
isClosing = true;
final int fabSize = getIntent().getIntExtra("fab_size", toolbar.getMeasuredHeight());
final int cx = (int) getIntent().getFloatExtra("fab_x", rootLayout.getMeasuredWidth() / 2) + (fabSize / 2);
final int cy = (int) getIntent().getFloatExtra("fab_y", rootLayout.getMeasuredHeight() / 2) + toolbar.getMeasuredHeight() + (fabSize / 2);
float initialRadius = Math.max(cx, cy);
final Animator circularReveal = ViewAnimationUtils.createCircularReveal(rootLayout, cx, cy, initialRadius, 0);
circularReveal.setDuration(300);
circularReveal.setInterpolator(new AccelerateInterpolator());
circularReveal.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
rootLayout.setVisibility(View.INVISIBLE);
finish();
overridePendingTransition(0, 0);
}
});
circularReveal.start();
}
private void circularRevealActivity() {
final int cx = rootLayout.getMeasuredWidth() / 2;
final int cy = rootLayout.getMeasuredHeight() / 2;
final float finalRadius = Math.max(cx, cy);
final Animator circularReveal = ViewAnimationUtils.createCircularReveal(rootLayout, cx, cy, 0, finalRadius);
circularReveal.setDuration(300);
circularReveal.setInterpolator(new DecelerateInterpolator());
rootLayout.setVisibility(View.VISIBLE);
circularReveal.start();
}
// Done button
@Override
public void onClick(View view) {
isClosing = true;
ServerModel model = new ServerModel();
model.name = inputName.getText().toString().trim();
model.url = inputUrl.getText().toString().trim();
model.status = ServerStatus.WAITING;
if (model.name.isEmpty()) {
nameTiLayout.setError(getString(R.string.please_enter_name));
isClosing = false;
return;
} else {
nameTiLayout.setError(null);
}
if (model.url.isEmpty()) {
urlTiLayout.setError(getString(R.string.please_enter_url));
isClosing = false;
return;
} else {
urlTiLayout.setError(null);
if (!Patterns.WEB_URL.matcher(model.url).find()) {
urlTiLayout.setError(getString(R.string.please_enter_valid_url));
isClosing = false;
return;
} else {
final Uri uri = Uri.parse(model.url);
if (uri.getScheme() == null)
model.url = "http://" + model.url;
}
}
String intervalStr = inputInterval.getText().toString().trim();
if (intervalStr.isEmpty()) intervalStr = "0";
model.checkInterval = Integer.parseInt(intervalStr);
switch (spinnerInterval.getSelectedItemPosition()) {
case 0: // minutes
model.checkInterval *= (60 * 1000);
break;
case 1: // hours
model.checkInterval *= (60 * 60 * 1000);
break;
case 2: // days
model.checkInterval *= (60 * 60 * 24 * 1000);
break;
default: // weeks
model.checkInterval *= (60 * 60 * 24 * 7 * 1000);
break;
}
model.lastCheck = System.currentTimeMillis() - model.checkInterval;
switch (responseValidationSpinner.getSelectedItemPosition()) {
case 0:
model.validationMode = ValidationMode.STATUS_CODE;
model.validationContent = null;
break;
case 1:
model.validationMode = ValidationMode.TERM_SEARCH;
model.validationContent = ((EditText) findViewById(R.id.responseValidationSearchTerm)).getText().toString().trim();
break;
case 2:
model.validationMode = ValidationMode.JAVASCRIPT;
model.validationContent = ((EditText) findViewById(R.id.responseValidationScriptInput)).getText().toString().trim();
break;
}
setResult(RESULT_OK, new Intent()
.putExtra("model", model));
finish();
overridePendingTransition(R.anim.fade_out, R.anim.fade_out);
}
}

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

@ -0,0 +1,326 @@
package com.afollestad.nocknock.ui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.ActivityOptions;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.graphics.Path;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.design.widget.FloatingActionButton;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.content.ContextCompat;
import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.text.Html;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.animation.PathInterpolator;
import android.widget.TextView;
import com.afollestad.bridge.Bridge;
import com.afollestad.inquiry.Inquiry;
import com.afollestad.materialdialogs.MaterialDialog;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.adapter.ServerAdapter;
import com.afollestad.nocknock.api.ServerModel;
import com.afollestad.nocknock.api.ValidationMode;
import com.afollestad.nocknock.dialogs.AboutDialog;
import com.afollestad.nocknock.services.CheckService;
import com.afollestad.nocknock.util.AlarmUtil;
import com.afollestad.nocknock.util.MathUtil;
import com.afollestad.nocknock.views.DividerItemDecoration;
public class MainActivity extends AppCompatActivity implements SwipeRefreshLayout.OnRefreshListener, View.OnClickListener, ServerAdapter.ClickListener {
private final static int ADD_SITE_RQ = 6969;
private final static int VIEW_SITE_RQ = 6923;
public final static String DB_NAME = "nock_nock";
public final static String SITES_TABLE_NAME_OLD = "sites";
public final static String SITES_TABLE_NAME = "site_models";
private FloatingActionButton mFab;
private RecyclerView mList;
private ServerAdapter mAdapter;
private TextView mEmptyText;
private SwipeRefreshLayout mRefreshLayout;
private ObjectAnimator mFabAnimator;
private float mOrigFabX;
private float mOrigFabY;
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Log.v("MainActivity", "Received " + intent.getAction());
if (CheckService.ACTION_RUNNING.equals(intent.getAction())) {
if (mRefreshLayout != null)
mRefreshLayout.setRefreshing(false);
} else {
final ServerModel model = (ServerModel) intent.getSerializableExtra("model");
if (mAdapter != null && mList != null && model != null) {
mList.post(() -> mAdapter.update(model));
}
}
}
};
@SuppressLint("CommitPrefEdits")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mAdapter = new ServerAdapter(this);
mEmptyText = (TextView) findViewById(R.id.emptyText);
mList = (RecyclerView) findViewById(R.id.list);
mList.setLayoutManager(new LinearLayoutManager(this));
mList.setAdapter(mAdapter);
mList.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST));
mRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipeRefreshLayout);
mRefreshLayout.setOnRefreshListener(this);
mRefreshLayout.setColorSchemeColors(ContextCompat.getColor(this, R.color.md_green),
ContextCompat.getColor(this, R.color.md_yellow),
ContextCompat.getColor(this, R.color.md_red));
mFab = (FloatingActionButton) findViewById(R.id.fab);
mFab.setOnClickListener(this);
Inquiry.newInstance(this, DB_NAME).build();
Bridge.config()
.defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)");
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);
if (!sp.getBoolean("migrated_db", false)) {
final Inquiry mdb = Inquiry.newInstance(this, DB_NAME)
.instanceName("migrate_db")
.build(false);
final ServerModel[] models = Inquiry.get(this)
.selectFrom(SITES_TABLE_NAME_OLD, ServerModel.class)
.projection("_id", "name", "url", "status", "checkInterval", "lastCheck", "reason")
.all();
if (models != null) {
Log.d("SiteMigration", "Migrating " + models.length + " sites to the new table.");
for (ServerModel model : models) {
model.validationMode = ValidationMode.STATUS_CODE;
model.validationContent = null;
}
//noinspection CheckResult
mdb.insertInto(SITES_TABLE_NAME, ServerModel.class)
.values(models)
.run();
mdb.dropTable(SITES_TABLE_NAME_OLD);
}
sp.edit().putBoolean("migrated_db", true).commit();
}
}
private void showRefreshTutorial() {
if (mAdapter.getItemCount() == 0) return;
final SharedPreferences pr = PreferenceManager.getDefaultSharedPreferences(this);
if (pr.getBoolean("shown_swipe_refresh_tutorial", false)) return;
mFab.hide();
final View tutorialView = findViewById(R.id.swipeRefreshTutorial);
tutorialView.setVisibility(View.VISIBLE);
tutorialView.setAlpha(0f);
tutorialView.animate().cancel();
tutorialView.animate().setDuration(300).alpha(1f).start();
findViewById(R.id.understoodBtn).setOnClickListener(view -> {
view.setOnClickListener(null);
findViewById(R.id.swipeRefreshTutorial).setVisibility(View.GONE);
pr.edit().putBoolean("shown_swipe_refresh_tutorial", true).commit();
mFab.show();
});
}
@Override
protected void onResume() {
super.onResume();
CheckService.isAppOpen(this, true);
try {
final IntentFilter filter = new IntentFilter();
filter.addAction(CheckService.ACTION_CHECK_UPDATE);
filter.addAction(CheckService.ACTION_RUNNING);
registerReceiver(mReceiver, filter);
} catch (Throwable t) {
t.printStackTrace();
}
refreshModels();
}
@Override
protected void onPause() {
super.onPause();
CheckService.isAppOpen(this, false);
if (isFinishing()) {
Inquiry.destroy(this);
}
NotificationManagerCompat.from(this).cancel(CheckService.NOTI_ID);
try {
unregisterReceiver(mReceiver);
} catch (Throwable t) {
t.printStackTrace();
}
}
private void refreshModels() {
mAdapter.clear();
mEmptyText.setVisibility(View.VISIBLE);
Inquiry.get(this)
.selectFrom(SITES_TABLE_NAME, ServerModel.class)
.all(this::setModels);
}
private void setModels(ServerModel[] models) {
mAdapter.set(models);
mEmptyText.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE);
AlarmUtil.setSiteChecks(this, models);
showRefreshTutorial();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_main, menu);
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.about) {
AboutDialog.show(this);
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void onRefresh() {
if (CheckService.isRunning(this)) {
mRefreshLayout.setRefreshing(false);
return;
}
startService(new Intent(this, CheckService.class));
}
// FAB clicked
@Override
public void onClick(View view) {
mOrigFabX = mFab.getX();
mOrigFabY = mFab.getY();
final Path curve = MathUtil.bezierCurve(mFab, mList);
if (mFabAnimator != null)
mFabAnimator.cancel();
mFabAnimator = ObjectAnimator.ofFloat(view, View.X, View.Y, curve);
mFabAnimator.setInterpolator(new PathInterpolator(.5f, .5f));
mFabAnimator.setDuration(300);
mFabAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
startActivityForResult(new Intent(MainActivity.this, AddSiteActivity.class)
.putExtra("fab_x", mOrigFabX)
.putExtra("fab_y", mOrigFabY)
.putExtra("fab_size", mFab.getMeasuredWidth())
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION), ADD_SITE_RQ);
mFab.postDelayed(() -> {
mFab.setX(mOrigFabX);
mFab.setY(mOrigFabY);
}, 600);
}
});
mFabAnimator.start();
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == RESULT_OK) {
final ServerModel model = (ServerModel) data.getSerializableExtra("model");
if (requestCode == ADD_SITE_RQ) {
mAdapter.add(model);
mEmptyText.setVisibility(View.GONE);
Inquiry.get(this).insertInto(SITES_TABLE_NAME, ServerModel.class)
.values(model)
.run(inserted -> {
AlarmUtil.setSiteChecks(MainActivity.this, model);
checkSite(MainActivity.this, model);
});
} else if (requestCode == VIEW_SITE_RQ) {
mAdapter.update(model);
AlarmUtil.setSiteChecks(MainActivity.this, model);
checkSite(MainActivity.this, model);
}
}
}
public static void removeSite(final Context context, final ServerModel model, final Runnable onRemoved) {
new MaterialDialog.Builder(context)
.title(R.string.remove_site)
.content(Html.fromHtml(context.getString(R.string.remove_site_prompt, model.name)))
.positiveText(R.string.remove)
.negativeText(android.R.string.cancel)
.onPositive((dialog, which) -> {
AlarmUtil.cancelSiteChecks(context, model);
final NotificationManagerCompat nm = NotificationManagerCompat.from(context);
nm.cancel(model.url, CheckService.NOTI_ID);
//noinspection CheckResult
final Inquiry rinq = Inquiry.newInstance(context, DB_NAME)
.instanceName("remove_site")
.build(false);
//noinspection CheckResult
rinq.deleteFrom(SITES_TABLE_NAME, ServerModel.class)
.where("_id = ?", model.id)
.run();
rinq.destroyInstance();
if (onRemoved != null)
onRemoved.run();
}).show();
}
public static void checkSite(Context context, ServerModel model) {
context.startService(new Intent(context, CheckService.class)
.putExtra(CheckService.MODEL_ID, model.id));
}
@Override
public void onSiteSelected(final int index, final ServerModel model, boolean longClick) {
if (longClick) {
new MaterialDialog.Builder(this)
.title(R.string.options)
.items(R.array.site_long_options)
.negativeText(android.R.string.cancel)
.itemsCallback((dialog, itemView, which, text) -> {
if (which == 0) {
checkSite(MainActivity.this, model);
} else {
removeSite(MainActivity.this, model, () -> {
mAdapter.remove(index);
mEmptyText.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE);
});
}
}).show();
} else {
startActivityForResult(new Intent(this, ViewSiteActivity.class)
.putExtra("model", model), VIEW_SITE_RQ,
ActivityOptions.makeSceneTransitionAnimation(this).toBundle());
}
}
}

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

@ -0,0 +1,340 @@
package com.afollestad.nocknock.ui;
import android.annotation.SuppressLint;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.Uri;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.util.Patterns;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;
import com.afollestad.bridge.Bridge;
import com.afollestad.inquiry.Inquiry;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.api.ServerModel;
import com.afollestad.nocknock.api.ServerStatus;
import com.afollestad.nocknock.api.ValidationMode;
import com.afollestad.nocknock.services.CheckService;
import com.afollestad.nocknock.util.TimeUtil;
import com.afollestad.nocknock.views.StatusImageView;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* @author Aidan Follestad (afollestad)
*/
public class ViewSiteActivity extends AppCompatActivity implements View.OnClickListener, Toolbar.OnMenuItemClickListener {
private StatusImageView iconStatus;
private EditText inputName;
private EditText inputUrl;
private EditText inputCheckInterval;
private Spinner checkIntervalSpinner;
private TextView textLastCheckResult;
private TextView textNextCheck;
private TextView textUrlWarning;
private Spinner responseValidationSpinner;
private ServerModel mModel;
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Log.v("ViewSiteActivity", "Received " + intent.getAction());
final ServerModel model = (ServerModel) intent.getSerializableExtra("model");
if (model != null) {
mModel = model;
update();
}
}
};
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_viewsite);
final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
toolbar.setNavigationOnClickListener(view -> finish());
toolbar.inflateMenu(R.menu.menu_viewsite);
toolbar.setOnMenuItemClickListener(this);
iconStatus = (StatusImageView) findViewById(R.id.iconStatus);
inputName = (EditText) findViewById(R.id.inputName);
inputUrl = (EditText) findViewById(R.id.inputUrl);
textUrlWarning = (TextView) findViewById(R.id.textUrlWarning);
inputCheckInterval = (EditText) findViewById(R.id.checkIntervalInput);
checkIntervalSpinner = (Spinner) findViewById(R.id.checkIntervalSpinner);
textLastCheckResult = (TextView) findViewById(R.id.textLastCheckResult);
textNextCheck = (TextView) findViewById(R.id.textNextCheck);
responseValidationSpinner = (Spinner) findViewById(R.id.responseValidationMode);
ArrayAdapter<String> intervalOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner,
getResources().getStringArray(R.array.interval_options));
intervalOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown);
checkIntervalSpinner.setAdapter(intervalOptionsAdapter);
inputUrl.setOnFocusChangeListener((view, hasFocus) -> {
if (!hasFocus) {
final String inputStr = inputUrl.getText().toString().trim();
if (inputStr.isEmpty()) return;
final Uri uri = Uri.parse(inputStr);
if (uri.getScheme() == null) {
inputUrl.setText("http://" + inputStr);
textUrlWarning.setVisibility(View.GONE);
} else if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
textUrlWarning.setVisibility(View.VISIBLE);
textUrlWarning.setText(R.string.warning_http_url);
} else {
textUrlWarning.setVisibility(View.GONE);
}
}
});
ArrayAdapter<String> validationOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner,
getResources().getStringArray(R.array.response_validation_options));
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown);
responseValidationSpinner.setAdapter(validationOptionsAdapter);
responseValidationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
final View searchTerm = findViewById(R.id.responseValidationSearchTerm);
final View javascript = findViewById(R.id.responseValidationScript);
final TextView modeDesc = (TextView) findViewById(R.id.validationModeDescription);
searchTerm.setVisibility(i == 1 ? View.VISIBLE : View.GONE);
javascript.setVisibility(i == 2 ? View.VISIBLE : View.GONE);
switch (i) {
case 0:
modeDesc.setText(R.string.validation_mode_status_desc);
break;
case 1:
modeDesc.setText(R.string.validation_mode_term_desc);
break;
case 2:
modeDesc.setText(R.string.validation_mode_javascript_desc);
break;
}
}
@Override
public void onNothingSelected(AdapterView<?> adapterView) {
}
});
mModel = (ServerModel) getIntent().getSerializableExtra("model");
update();
Bridge.config()
.defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)");
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
if (intent != null && intent.hasExtra("model")) {
mModel = (ServerModel) intent.getSerializableExtra("model");
update();
}
}
@SuppressLint({"SetTextI18n", "SwitchIntDef"})
private void update() {
final SimpleDateFormat df = new SimpleDateFormat("MMMM dd, hh:mm:ss a", Locale.getDefault());
iconStatus.setStatus(mModel.status);
inputName.setText(mModel.name);
inputUrl.setText(mModel.url);
if (mModel.lastCheck == 0) {
textLastCheckResult.setText(R.string.none);
} else {
switch (mModel.status) {
case ServerStatus.CHECKING:
textLastCheckResult.setText(R.string.checking_status);
break;
case ServerStatus.ERROR:
textLastCheckResult.setText(mModel.reason);
break;
case ServerStatus.OK:
textLastCheckResult.setText(R.string.everything_checks_out);
break;
case ServerStatus.WAITING:
textLastCheckResult.setText(R.string.waiting);
break;
}
}
if (mModel.checkInterval == 0) {
textNextCheck.setText(R.string.none_turned_off);
inputCheckInterval.setText("");
checkIntervalSpinner.setSelection(0);
} else {
long lastCheck = mModel.lastCheck;
if (lastCheck == 0) lastCheck = System.currentTimeMillis();
textNextCheck.setText(df.format(new Date(lastCheck + mModel.checkInterval)));
if (mModel.checkInterval >= TimeUtil.WEEK) {
inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.WEEK))));
checkIntervalSpinner.setSelection(3);
} else if (mModel.checkInterval >= TimeUtil.DAY) {
inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.DAY))));
checkIntervalSpinner.setSelection(2);
} else if (mModel.checkInterval >= TimeUtil.HOUR) {
inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.HOUR))));
checkIntervalSpinner.setSelection(1);
} else if (mModel.checkInterval >= TimeUtil.MINUTE) {
inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.MINUTE))));
checkIntervalSpinner.setSelection(0);
} else {
inputCheckInterval.setText("0");
checkIntervalSpinner.setSelection(0);
}
}
responseValidationSpinner.setSelection(mModel.validationMode - 1);
switch (mModel.validationMode) {
case ValidationMode.TERM_SEARCH:
((TextView) findViewById(R.id.responseValidationSearchTerm)).setText(mModel.validationContent);
break;
case ValidationMode.JAVASCRIPT:
((TextView) findViewById(R.id.responseValidationScriptInput)).setText(mModel.validationContent);
break;
}
findViewById(R.id.doneBtn).setOnClickListener(this);
}
@Override
protected void onResume() {
super.onResume();
try {
final IntentFilter filter = new IntentFilter();
filter.addAction(CheckService.ACTION_CHECK_UPDATE);
// filter.addAction(CheckService.ACTION_RUNNING);
registerReceiver(mReceiver, filter);
} catch (Throwable t) {
t.printStackTrace();
}
}
@Override
protected void onPause() {
super.onPause();
try {
unregisterReceiver(mReceiver);
} catch (Throwable t) {
t.printStackTrace();
}
}
private void performSave(boolean withValidation) {
mModel.name = inputName.getText().toString().trim();
mModel.url = inputUrl.getText().toString().trim();
mModel.status = ServerStatus.WAITING;
if (withValidation && mModel.name.isEmpty()) {
inputName.setError(getString(R.string.please_enter_name));
return;
} else {
inputName.setError(null);
}
if (withValidation && mModel.url.isEmpty()) {
inputUrl.setError(getString(R.string.please_enter_url));
return;
} else {
inputUrl.setError(null);
if (withValidation && !Patterns.WEB_URL.matcher(mModel.url).find()) {
inputUrl.setError(getString(R.string.please_enter_valid_url));
return;
} else {
final Uri uri = Uri.parse(mModel.url);
if (uri.getScheme() == null)
mModel.url = "http://" + mModel.url;
}
}
String intervalStr = inputCheckInterval.getText().toString().trim();
if (intervalStr.isEmpty()) intervalStr = "0";
mModel.checkInterval = Integer.parseInt(intervalStr);
switch (checkIntervalSpinner.getSelectedItemPosition()) {
case 0: // minutes
mModel.checkInterval *= (60 * 1000);
break;
case 1: // hours
mModel.checkInterval *= (60 * 60 * 1000);
break;
case 2: // days
mModel.checkInterval *= (60 * 60 * 24 * 1000);
break;
default: // weeks
mModel.checkInterval *= (60 * 60 * 24 * 7 * 1000);
break;
}
mModel.lastCheck = System.currentTimeMillis() - mModel.checkInterval;
switch (responseValidationSpinner.getSelectedItemPosition()) {
case 0:
mModel.validationMode = ValidationMode.STATUS_CODE;
mModel.validationContent = null;
break;
case 1:
mModel.validationMode = ValidationMode.TERM_SEARCH;
mModel.validationContent = ((EditText) findViewById(R.id.responseValidationSearchTerm)).getText().toString().trim();
break;
case 2:
mModel.validationMode = ValidationMode.JAVASCRIPT;
mModel.validationContent = ((EditText) findViewById(R.id.responseValidationScriptInput)).getText().toString().trim();
break;
}
final Inquiry inq = Inquiry.newInstance(this, MainActivity.DB_NAME)
.build(false);
//noinspection CheckResult
inq.update(MainActivity.SITES_TABLE_NAME, ServerModel.class)
.where("_id = ?", mModel.id)
.values(mModel)
.run();
inq.destroyInstance();
}
// Save button
@Override
public void onClick(View view) {
performSave(true);
setResult(RESULT_OK, new Intent().putExtra("model", mModel));
finish();
}
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.refresh:
performSave(false);
MainActivity.checkSite(this, mModel);
return true;
case R.id.remove:
MainActivity.removeSite(this, mModel, this::finish);
return true;
}
return false;
}
}

View file

@ -1,235 +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 android.annotation.SuppressLint
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.lifecycle.Observer
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.ui.viewsite.KEY_SITE
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection
import com.afollestad.nocknock.utilities.livedata.distinct
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.headersLayout
import kotlinx.android.synthetic.main.activity_addsite.inputName
import kotlinx.android.synthetic.main.activity_addsite.inputTags
import kotlinx.android.synthetic.main.activity_addsite.inputUrl
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.scriptInputLayout
import kotlinx.android.synthetic.main.activity_addsite.scrollView
import kotlinx.android.synthetic.main.activity_addsite.sslCertificateBrowse
import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput
import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
import kotlinx.android.synthetic.main.include_app_bar.toolbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
/** @author Aidan Follestad (@afollestad) */
class AddSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
private val viewModel by viewModel<AddSiteViewModel>()
private lateinit var validationForm: Form
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_addsite)
setupUi()
setupValidation()
lifecycle.addObserver(viewModel)
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as? Site
model?.let { viewModel.prePopulateFromModel(model) }
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
// Name
inputName.attachLiveData(this, viewModel.name)
// 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() }
}
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
resources.getStringArray(R.array.response_validation_options)
)
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
// SSL certificate
sslCertificateBrowse.setOnClickListener {
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
addCategory(CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
}
}
private fun setupValidation() {
validationForm = form {
input(inputName, name = "Name") {
isNotEmpty().description(R.string.please_enter_name)
}
input(inputUrl, name = "URL") {
isNotEmpty().description(R.string.please_enter_url)
isUrl().description(R.string.please_enter_valid_url)
}
input(responseTimeoutInput, name = "Timeout", optional = true) {
isNumber().greaterThan(0)
.description(R.string.please_enter_networkTimeout)
}
input(responseValidationSearchTerm, name = "Search term") {
conditional(responseValidationSearchTerm.isVisibleCondition()) {
isNotEmpty().description(R.string.please_enter_search_term)
}
}
input(sslCertificateInput, name = "Certificate Path", optional = true) {
isUri().hasScheme("file", "content")
.that { it.host != null }
.description(R.string.please_enter_validCertUri)
}
submitWith(toolbar.menu, R.id.commit) {
viewModel.commit {
setResult(RESULT_OK)
finish()
}
}
}
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
visibility = viewModel.onValidationScriptVisibility(),
form = validationForm
)
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
form = validationForm
)
// Retry Policy
retryPolicyLayout.attach(
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes,
form = validationForm
)
}
override fun onResume() {
super.onResume()
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
}
}
}

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,149 +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 android.content.Intent
import android.os.Bundle
import androidx.lifecycle.Observer
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.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 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.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
/** @author Aidan Follestad (@afollestad) */
class MainActivity : DarkModeSwitchActivity() {
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)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupUi()
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
}
}
siteAdapter = SiteAdapter(this::onSiteSelected)
list.run {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = siteAdapter
addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL))
}
tagAdapter = TagAdapter(viewModel::onTagSelection)
tagsList.run {
layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false)
adapter = tagAdapter
}
fab.setOnClickListener { addSite() }
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.let(::processIntent)
}
private fun onSiteSelected(
model: Site,
longClick: Boolean
) {
if (longClick) {
MaterialDialog(this).show {
title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
0 -> viewModel.refreshSite(model)
1 -> addSiteForDuplication(model)
2 -> maybeRemoveSite(model)
}
}
}
} else {
viewSite(model)
}
}
}

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.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.toHtml
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.viewsite.KEY_SITE
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL
internal const val VIEW_SITE_RQ = 6923
internal const val ADD_SITE_RQ = 6969
// ADD
internal fun MainActivity.addSite() {
startActivityForResult(intentToAdd(), ADD_SITE_RQ)
}
internal fun MainActivity.addSiteForDuplication(site: Site) {
startActivityForResult(intentToAdd(site), ADD_SITE_RQ)
}
private fun MainActivity.intentToAdd(model: Site? = null) =
Intent(this, AddSiteActivity::class.java).apply {
model?.let { putExtra(KEY_SITE, it) }
}
// VIEW
internal fun MainActivity.viewSite(model: Site) {
startActivityForResult(intentToView(model), VIEW_SITE_RQ)
}
private fun MainActivity.intentToView(model: Site) =
Intent(this, ViewSiteActivity::class.java).apply {
putExtra(KEY_SITE, model)
}
// MISC
internal fun MainActivity.maybeRemoveSite(model: Site) {
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) }
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
viewSite(model)
}
}

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,291 +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 android.annotation.SuppressLint
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.lifecycle.Observer
import com.afollestad.nocknock.R
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection
import com.afollestad.nocknock.utilities.livedata.distinct
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.headersLayout
import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
import kotlinx.android.synthetic.main.activity_viewsite.inputName
import kotlinx.android.synthetic.main.activity_viewsite.inputTags
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.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.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
/** @author Aidan Follestad (@afollestad) */
class ViewSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
internal val viewModel by viewModel<ViewSiteViewModel>()
private lateinit var validationForm: Form
private val intentProvider by inject<IntentProvider>()
private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) {
viewModel.setModel(it)
}
}
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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() }
}
setOnMenuItemClickListener {
when (it.itemId) {
R.id.remove -> maybeRemoveSite()
R.id.disableChecks -> maybeDisableChecks()
}
true
}
}
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
resources.getStringArray(R.array.response_validation_options)
)
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
})
// Done item text
viewModel.onDoneButtonText()
.observe(this, Observer {
toolbar.menu.findItem(R.id.commit)
.setTitle(it)
})
// SSL certificate
sslCertificateBrowse.setOnClickListener {
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
addCategory(CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
}
}
private fun setupValidation() {
validationForm = form {
input(inputName, name = "Name") {
isNotEmpty().description(R.string.please_enter_name)
}
input(inputUrl, name = "URL") {
isNotEmpty().description(R.string.please_enter_url)
isUrl().description(R.string.please_enter_valid_url)
}
input(responseValidationSearchTerm, name = "Search term") {
conditional(responseValidationSearchTerm.isVisibleCondition()) {
isNotEmpty().description(R.string.please_enter_search_term)
}
}
input(responseTimeoutInput, name = "Timeout", optional = true) {
isNumber().greaterThan(0)
.description(R.string.please_enter_networkTimeout)
}
input(sslCertificateInput, name = "Certificate Path", optional = true) {
isUri().hasScheme("file", "content")
.that { it.host != null }
.description(R.string.please_enter_validCertUri)
}
submitWith(toolbar.menu, R.id.commit) {
viewModel.commit { finish() }
}
}
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
visibility = viewModel.onValidationScriptVisibility(),
form = validationForm
)
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
form = validationForm
)
// Retry Policy
retryPolicyLayout.attach(
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes,
form = validationForm
)
}
override fun onResume() {
super.onResume()
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
}
}
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)
}
}
}

View file

@ -1,64 +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 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.toHtml
import com.afollestad.nocknock.utilities.ext.animateRotation
import kotlinx.android.synthetic.main.include_app_bar.toolbar
const val KEY_SITE = "site_model"
internal fun ViewSiteActivity.maybeRemoveSite() {
val model = viewModel.site
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() }
}
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.maybeDisableChecks() {
val model = viewModel.site
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() }
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) {
val refreshIcon = toolbar.menu.findItem(R.id.refresh)
.actionView as ImageView
if (status.isPending()) {
refreshIcon.animateRotation()
} else {
refreshIcon.run {
animate().cancel()
rotation = 0f
}
}
}

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,59 @@
package com.afollestad.nocknock.util;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import com.afollestad.nocknock.api.ServerModel;
import com.afollestad.nocknock.services.CheckService;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* @author Aidan Follestad (afollestad)
*/
public class AlarmUtil {
private final static int BASE_RQC = 69;
public static PendingIntent getSiteIntent(Context context, ServerModel site) {
return PendingIntent.getService(context,
BASE_RQC + (int) site.id,
new Intent(context, CheckService.class)
.putExtra(CheckService.MODEL_ID, site.id),
PendingIntent.FLAG_UPDATE_CURRENT);
}
private static AlarmManager am(Context context) {
return (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
}
public static void cancelSiteChecks(Context context, ServerModel site) {
PendingIntent pi = getSiteIntent(context, site);
am(context).cancel(pi);
}
public static void setSiteChecks(Context context, ServerModel site) {
cancelSiteChecks(context, site);
if (site.checkInterval <= 0) return;
if (site.lastCheck <= 0)
site.lastCheck = System.currentTimeMillis();
final long nextCheck = site.lastCheck + site.checkInterval;
final AlarmManager aMgr = am(context);
final PendingIntent serviceIntent = getSiteIntent(context, site);
aMgr.setRepeating(AlarmManager.RTC_WAKEUP, nextCheck, site.checkInterval, serviceIntent);
final SimpleDateFormat df = new SimpleDateFormat("EEE MMM dd hh:mm:ssa z yyyy", Locale.getDefault());
Log.d("AlarmUtil", String.format(Locale.getDefault(), "Set site check alarm for %s (%s), check interval: %d, next check: %s",
site.name, site.url, site.checkInterval, df.format(new Date(nextCheck))));
}
public static void setSiteChecks(Context context, ServerModel[] sites) {
if (sites == null || sites.length == 0) return;
for (ServerModel site : sites)
setSiteChecks(context, site);
}
}

View file

@ -0,0 +1,72 @@
package com.afollestad.nocknock.util;
import android.util.Log;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.EvaluatorException;
import org.mozilla.javascript.Function;
import org.mozilla.javascript.Scriptable;
/**
* @author Aidan Follestad (afollestad)
*/
public class JsUtil {
public static String exec(String code, String response) {
try {
final String func = String.format(
"function validate(response) { " +
"try { " +
"%s " +
"} catch(e) { " +
"return e; " +
"} " +
"}", code.replace("\n", " "));
// Every Rhino VM begins with the enter()
// This Context is not Android's Context
Context rhino = Context.enter();
// Turn off optimization to make Rhino Android compatible
rhino.setOptimizationLevel(-1);
try {
Scriptable scope = rhino.initStandardObjects();
// Note the forth argument is 1, which means the JavaScript source has
// been compressed to only one line using something like YUI
rhino.evaluateString(scope, func, "JavaScript", 1, null);
// Get the functionName defined in JavaScriptCode
Function jsFunction = (Function) scope.get("validate", scope);
// Call the function with params
Object jsResult = jsFunction.call(rhino, scope, scope, new Object[]{response});
// Parse the jsResult object to a String
String result = Context.toString(jsResult);
boolean success = result != null && result.equals("true");
String message = "The script returned a value other than true!";
if (!success && result != null && !result.equals("false")) {
if (result.equals("undefined")) {
message = "The script did not return or throw anything!";
} else {
message = result;
}
}
Log.d("JsUtil", "Evaluated to " + message + " (" + success + "): " + code);
return !success ? message : null;
} finally {
Context.exit();
}
} catch (EvaluatorException e) {
return e.getMessage();
}
}
private JsUtil() {
}
}

View file

@ -0,0 +1,32 @@
package com.afollestad.nocknock.util;
import android.graphics.Path;
import android.support.design.widget.FloatingActionButton;
import android.view.View;
/**
* @author Aidan Follestad (afollestad)
*/
public final class MathUtil {
public static Path bezierCurve(FloatingActionButton fab, View rootView) {
final int fabCenterX = (int) (fab.getX() + fab.getMeasuredWidth() / 2);
final int fabCenterY = (int) (fab.getY() + fab.getMeasuredHeight() / 2);
final int endCenterX = (rootView.getMeasuredWidth() / 2) - (fab.getMeasuredWidth() / 2);
final int endCenterY = (rootView.getMeasuredHeight() / 2) - (fab.getMeasuredHeight() / 2);
final int halfX = (fabCenterX - endCenterX) / 2;
final int halfY = (fabCenterY - endCenterY) / 2;
int mControlX = endCenterX + halfX;
int mControlY = endCenterY + halfY;
mControlY -= halfY;
mControlX += halfX;
Path path = new Path();
path.moveTo(fab.getX(), fab.getY());
path.quadTo(mControlX, mControlY, endCenterX, endCenterY);
return path;
}
}

View file

@ -0,0 +1,18 @@
package com.afollestad.nocknock.util;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
/**
* @author Aidan Follestad (afollestad)
*/
public class NetworkUtil {
public static boolean hasInternet(Context context) {
final ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
final NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
return activeNetwork != null &&
activeNetwork.isConnectedOrConnecting();
}
}

View file

@ -0,0 +1,32 @@
package com.afollestad.nocknock.util;
/**
* @author Aidan Follestad (afollestad)
*/
public class TimeUtil {
public final static long SECOND = 1000;
public final static long MINUTE = SECOND * 60;
public final static long HOUR = MINUTE * 60;
public final static long DAY = HOUR * 24;
public final static long WEEK = DAY * 7;
public final static long MONTH = WEEK * 4;
public static String str(long duration) {
if (duration <= 0) {
return "";
} else if (duration >= MONTH) {
return (int) Math.ceil(((float) duration / (float) MONTH)) + "mo";
} else if (duration >= WEEK) {
return (int) Math.ceil(((float) duration / (float) WEEK)) + "w";
} else if (duration >= DAY) {
return (int) Math.ceil(((float) duration / (float) DAY)) + "d";
} else if (duration >= HOUR) {
return (int) Math.ceil(((float) duration / (float) HOUR)) + "h";
} else if (duration >= MINUTE) {
return (int) Math.ceil(((float) duration / (float) MINUTE)) + "m";
} else {
return "<1m";
}
}
}

View file

@ -0,0 +1,104 @@
package com.afollestad.nocknock.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.View;
/*
* Copyright (C) 2014 The Android Open Source Project
*
* 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.
*/
public class DividerItemDecoration extends RecyclerView.ItemDecoration {
private static final int[] ATTRS = new int[]{
android.R.attr.listDivider
};
public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;
public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;
private Drawable mDivider;
private int mOrientation;
public DividerItemDecoration(Context context, int orientation) {
final TypedArray a = context.obtainStyledAttributes(ATTRS);
mDivider = a.getDrawable(0);
a.recycle();
setOrientation(orientation);
}
public void setOrientation(int orientation) {
if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
throw new IllegalArgumentException("invalid orientation");
}
mOrientation = orientation;
}
@Override
public void onDraw(Canvas c, RecyclerView parent) {
if (mOrientation == VERTICAL_LIST) {
drawVertical(c, parent);
} else {
drawHorizontal(c, parent);
}
}
public void drawVertical(Canvas c, RecyclerView parent) {
final int left = parent.getPaddingLeft();
final int right = parent.getWidth() - parent.getPaddingRight();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int top = child.getBottom() + params.bottomMargin;
final int bottom = top + mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
}
}
public void drawHorizontal(Canvas c, RecyclerView parent) {
final int top = parent.getPaddingTop();
final int bottom = parent.getHeight() - parent.getPaddingBottom();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int left = child.getRight() + params.rightMargin;
final int right = left + mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
}
}
@Override
public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
if (mOrientation == VERTICAL_LIST) {
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
}

View file

@ -0,0 +1,47 @@
package com.afollestad.nocknock.views;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ImageView;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.api.ServerStatus;
/**
* @author Aidan Follestad (afollestad)
*/
public class StatusImageView extends ImageView {
public StatusImageView(Context context) {
super(context);
setStatus(ServerStatus.OK);
}
public StatusImageView(Context context, AttributeSet attrs) {
super(context, attrs);
setStatus(ServerStatus.OK);
}
public StatusImageView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setStatus(ServerStatus.OK);
}
public void setStatus(@ServerStatus.Enum int status) {
switch (status) {
case ServerStatus.CHECKING:
case ServerStatus.WAITING:
setImageResource(R.drawable.status_progress);
setBackgroundResource(R.drawable.yellow_circle);
break;
case ServerStatus.ERROR:
setImageResource(R.drawable.status_error);
setBackgroundResource(R.drawable.red_circle);
break;
case ServerStatus.OK:
setImageResource(R.drawable.status_ok);
setBackgroundResource(R.drawable.green_circle);
break;
}
}
}

View file

@ -0,0 +1,11 @@
<?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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

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"/>
</shape>
<size android:height="1dp" />
<solid android:color="@color/dividerColor" />
</shape>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/md_green" />
<stroke android:color="#424242" />
<size
android:width="@dimen/list_circle_size"
android:height="@dimen/list_circle_size" />
</shape>

View file

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="?iconColor"
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"/>
<path
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

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="?iconColor"
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>
<path
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

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="?iconColor"
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>
<path
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

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#fff"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
<path
android:fillColor="#fff"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</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

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="84dp"
android:height="84dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:pathData="M20,12l-1.41,-1.41L13,16.17V4h-2v12.17l-5.58,-5.59L4,12l8,8 8,-8z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/md_red" />
<stroke android:color="#424242"/>
<size
android:width="@dimen/list_circle_size"
android:height="@dimen/list_circle_size" />
</shape>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#fff"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z" />
</vector>

View file

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

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#fff"
android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</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

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/md_yellow" />
<stroke android:color="#424242" />
<size
android:width="@dimen/list_circle_size"
android:height="@dimen/list_circle_size" />
</shape>

View file

@ -1,222 +1,242 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout 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/rootView"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
android:background="?colorPrimary"
android:orientation="vertical">
<LinearLayout
android:id="@+id/rootView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<include layout="@layout/include_app_bar"/>
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:navigationIcon="@drawable/ic_action_close"
app:title="@string/add_site"
app:titleTextColor="?android:textColorPrimary" />
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset_double"
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"
/>
<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"
/>
<TextView
android:text="@string/site_url"
style="@style/InputForm.Header"
/>
<EditText
android:id="@+id/inputUrl"
android:hint="@string/site_url_hint"
android:inputType="textUri"
android:nextFocusDown="@+id/inputTags"
tools:ignore="Autofill"
style="@style/InputForm.Field"
/>
<TextView
android:id="@+id/textUrlWarning"
android:text="@string/warning_http_url"
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"
/>
<include layout="@layout/include_divider"/>
<com.afollestad.nocknock.viewcomponents.interval.ValidationIntervalLayout
android:id="@+id/checkIntervalLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
/>
<TextView
android:id="@+id/responseValidationLabel"
android:text="@string/response_validation_mode"
style="@style/InputForm.Header"
/>
<Spinner
android:id="@+id/responseValidationMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<EditText
android:id="@+id/responseValidationSearchTerm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset_less"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="-4dp"
android:hint="@string/search_term"
android:visibility="gone"
tools:ignore="Autofill,TextFields"
style="@style/NockText.Body"
/>
<com.afollestad.nocknock.viewcomponents.js.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"
/>
<TextView
android:id="@+id/validationModeDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset_half"
android:lineSpacingMultiplier="1.2"
android:text="@string/validation_mode_status_desc"
style="@style/NockText.Body.Light"
/>
<include layout="@layout/include_divider"/>
<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_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"
/>
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset">
<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"
/>
<android.support.design.widget.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">
<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"
/>
<android.support.design.widget.TextInputEditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:textColor="?android:textColorPrimary"
android:textColorHint="?android:textColorSecondary"
android:textSize="@dimen/body_font_size" />
</android.support.design.widget.TextInputLayout>
<android.support.design.widget.TextInputLayout
android:id="@+id/urlTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_half">
<android.support.design.widget.TextInputEditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"
android:hint="@string/site_url"
android:inputType="textUri"
android:textColor="?android:textColorPrimary"
android:textColorHint="?android:textColorSecondary"
android:textSize="@dimen/body_font_size" />
</android.support.design.widget.TextInputLayout>
<TextView
android:id="@+id/textUrlWarning"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:fontFamily="sans-serif-light"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/caption_font_size"
android:visibility="gone"
tools:text="Warning: this app checks for server availability with HTTP requests. It's recommended that you use an HTTP URL." />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset"
android:background="@color/dividerColorDark" />
<TextView
android:id="@+id/checkIntervalLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:fontFamily="sans-serif"
android:text="@string/check_interval"
android:textColor="?colorAccent"
android:textSize="@dimen/caption_font_size" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2">
<EditText
android:id="@+id/checkIntervalInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginEnd="@dimen/content_inset_half"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:fontFamily="sans-serif-light"
android:hint="0"
android:inputType="number"
android:textSize="@dimen/body_font_size"
tools:ignore="HardcodedText,LabelFor" />
<Spinner
android:id="@+id/checkIntervalSpinner"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="-4dp"
android:layout_weight="1" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset"
android:background="@color/dividerColorDark" />
<TextView
android:id="@+id/responseValidation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:fontFamily="sans-serif"
android:text="@string/response_validation_mode"
android:textColor="?colorAccent"
android:textSize="@dimen/caption_font_size" />
<Spinner
android:id="@+id/responseValidationMode"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<EditText
android:id="@+id/responseValidationSearchTerm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset_less"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="-4dp"
android:fontFamily="sans-serif-light"
android:hint="@string/search_term"
android:textSize="@dimen/body_font_size"
android:visibility="gone" />
<HorizontalScrollView
android:id="@+id/responseValidationScript"
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="@color/colorPrimaryDark"
android:elevation="@dimen/fab_elevation"
android:padding="@dimen/content_inset_half"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="serif-monospace"
android:lineSpacingMultiplier="1.4"
android:singleLine="true"
android:text="@string/function_declaration"
android:textSize="@dimen/code_font_size" />
<EditText
android:id="@+id/responseValidationScriptInput"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@null"
android:fontFamily="serif-monospace"
android:gravity="top"
android:hint="@string/default_js"
android:inputType="textMultiLine"
android:lineSpacingMultiplier="1.4"
android:paddingBottom="@dimen/content_inset_less"
android:paddingEnd="@dimen/content_inset_more"
android:paddingStart="@dimen/content_inset_more"
android:paddingTop="@dimen/content_inset_less"
android:scrollHorizontally="true"
android:textSize="@dimen/code_font_size"
tools:ignore="LabelFor,RtlSymmetry" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="serif-monospace"
android:text="@string/function_end"
android:textSize="@dimen/code_font_size" />
</LinearLayout>
</HorizontalScrollView>
<TextView
android:id="@+id/validationModeDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset_half"
android:fontFamily="sans-serif-light"
android:lineSpacingMultiplier="1.2"
android:text="@string/validation_mode_status_desc"
android:textSize="@dimen/body_font_size" />
<Button
android:id="@+id/doneBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/done"
android:textColor="#fff" />
</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"
/>
</LinearLayout>
</ScrollView>
</LinearLayout>
<com.afollestad.nocknock.viewcomponents.LoadingIndicatorFrame
android:id="@+id/loadingProgress"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</FrameLayout>
</LinearLayout>

View file

@ -1,71 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<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:id="@+id/rootView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainActivity"
>
tools:context=".ui.MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<include layout="@layout/include_app_bar"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tags_list"
<android.support.v4.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
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:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
<android.support.v7.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</android.support.v4.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/emptyText"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
/>
android:layout_gravity="center"
android:layout_marginBottom="@dimen/content_inset"
android:fontFamily="sans-serif-light"
android:gravity="center"
android:text="@string/no_sites_added"
android:textSize="@dimen/title_font_size"
android:textStyle="italic" />
</LinearLayout>
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
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"
app:useCompatPadding="true" />
<include layout="@layout/include_empty_view"/>
<LinearLayout
android:id="@+id/swipeRefreshTutorial"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#BF000000"
android:orientation="vertical"
android:padding="@dimen/content_inset"
android:visibility="gone">
<com.google.android.material.button.MaterialButton
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"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2">
<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"
/>
<ImageView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:scaleType="center"
android:src="@drawable/ic_down_arrow"
android:tint="#fff"
tools:ignore="ContentDescription" />
</FrameLayout>
<ImageView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:scaleType="center"
android:src="@drawable/ic_down_arrow"
android:tint="#fff"
tools:ignore="ContentDescription" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="@dimen/content_inset_more"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:lineSpacingMultiplier="1.6"
android:text="@string/swipe_refresh_hint"
android:textColor="#fff"
android:textSize="@dimen/medium_text_size" />
<Button
android:id="@+id/understoodBtn"
android:layout_width="@dimen/tutorial_button_width"
android:layout_height="@dimen/button_height"
android:layout_gravity="center_horizontal"
android:layout_marginTop="@dimen/content_inset_double"
android:text="@string/understood"
android:theme="@style/AccentButton" />
</LinearLayout>
</FrameLayout>

View file

@ -1,309 +1,295 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout 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/rootView"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
android:background="?colorPrimary"
android:orientation="vertical">
<LinearLayout
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"/>
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:navigationIcon="@drawable/ic_action_close"
app:title="@string/view_site"
app:titleTextColor="?android:textColorPrimary" />
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset_double"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_less"
>
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@null"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:nextFocusDown="@+id/inputUrl"
android:singleLine="true"
android:transitionName="site_name"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Header"
/>
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset_quarter"
android:orientation="horizontal"
>
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half">
<com.afollestad.nocknock.viewcomponents.StatusImageView
android:id="@+id/iconStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="@dimen/content_inset"
android:scaleType="centerInside"
android:transitionName="status_image_view"
tools:ignore="ContentDescription,UnusedAttribute"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<EditText
android:id="@+id/inputUrl"
<LinearLayout
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:transitionName="site_url"
tools:ignore="Autofill,UnusedAttribute"
style="@style/NockText.Body"
/>
android:orientation="horizontal">
<com.afollestad.nocknock.views.StatusImageView
android:id="@+id/iconStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="@dimen/content_inset"
android:scaleType="centerInside"
android:transitionName="status_image_view"
tools:ignore="ContentDescription" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<EditText
android:id="@+id/inputName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"
android:hint="@string/site_name"
android:inputType="textPersonName|textCapWords|textAutoCorrect"
android:singleLine="true"
android:textColor="?android:textColorPrimary"
android:textColorHint="?android:textColorSecondary"
android:textSize="@dimen/body_font_size"
android:transitionName="site_name" />
<EditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"
android:hint="@string/site_url"
android:inputType="textUri"
android:singleLine="true"
android:textColor="?android:textColorPrimary"
android:textColorHint="?android:textColorSecondary"
android:textSize="@dimen/body_font_size"
android:transitionName="site_url" />
<TextView
android:id="@+id/textUrlWarning"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:layout_marginTop="@dimen/list_text_spacing"
android:fontFamily="sans-serif-light"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/caption_font_size"
android:visibility="gone"
tools:text="Warning: this app checks for server availability with HTTP requests. It's recommended that you use an HTTP URL." />
</LinearLayout>
</LinearLayout>
<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/textUrlWarning"
android:id="@+id/checkIntervalLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:layout_marginTop="@dimen/list_text_spacing"
android:visibility="gone"
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"
/>
android:layout_marginTop="@dimen/content_inset"
android:fontFamily="sans-serif"
android:text="@string/check_interval"
android:textColor="?colorAccent"
android:textSize="@dimen/caption_font_size" />
<EditText
android:id="@+id/inputTags"
<LinearLayout
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"
/>
android:orientation="horizontal"
android:weightSum="2">
</LinearLayout>
<EditText
android:id="@+id/checkIntervalInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginEnd="@dimen/content_inset_half"
android:layout_marginStart="-4dp"
android:layout_weight="1"
android:fontFamily="sans-serif-light"
android:hint="0"
android:inputType="number"
android:textSize="@dimen/body_font_size"
tools:ignore="HardcodedText,LabelFor" />
<Spinner
android:id="@+id/checkIntervalSpinner"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="-4dp"
android:layout_weight="1" />
</LinearLayout>
<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/responseValidation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:fontFamily="sans-serif"
android:text="@string/response_validation_mode"
android:textColor="?colorAccent"
android:textSize="@dimen/caption_font_size" />
<Spinner
android:id="@+id/responseValidationMode"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<EditText
android:id="@+id/responseValidationSearchTerm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset_less"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="-4dp"
android:fontFamily="sans-serif-light"
android:hint="@string/search_term"
android:textSize="@dimen/body_font_size"
android:visibility="gone" />
<HorizontalScrollView
android:id="@+id/responseValidationScript"
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="@color/colorPrimaryDark"
android:elevation="@dimen/fab_elevation"
android:padding="@dimen/content_inset_half"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="serif-monospace"
android:lineSpacingMultiplier="1.4"
android:singleLine="true"
android:text="@string/function_declaration"
android:textSize="@dimen/code_font_size" />
<EditText
android:id="@+id/responseValidationScriptInput"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@null"
android:fontFamily="serif-monospace"
android:gravity="top"
android:hint="@string/default_js"
android:inputType="textMultiLine"
android:lineSpacingMultiplier="1.4"
android:paddingBottom="@dimen/content_inset_less"
android:paddingEnd="@dimen/content_inset_more"
android:paddingStart="@dimen/content_inset_more"
android:paddingTop="@dimen/content_inset_less"
android:scrollHorizontally="true"
android:textSize="@dimen/code_font_size"
tools:ignore="LabelFor,RtlSymmetry" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="serif-monospace"
android:text="@string/function_end"
android:textSize="@dimen/code_font_size" />
</LinearLayout>
</HorizontalScrollView>
<TextView
android:id="@+id/validationModeDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset_half"
android:fontFamily="sans-serif-light"
android:lineSpacingMultiplier="1.2"
android:text="@string/validation_mode_status_desc"
android:textSize="@dimen/body_font_size" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset"
android:background="@color/dividerColorDark" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/last_check_result"
android:textColor="?colorAccent"
android:textSize="@dimen/caption_font_size" />
<TextView
android:id="@+id/textLastCheckResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:fontFamily="sans-serif"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/medium_text_size"
tools:text="Everything checks out!" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/next_check"
android:textColor="?colorAccent"
android:textSize="@dimen/caption_font_size" />
<TextView
android:id="@+id/textNextCheck"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:fontFamily="sans-serif"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/medium_text_size"
tools:text="In 2 hours" />
<Button
android:id="@+id/doneBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_more"
android:text="@string/save"
android:textColor="#fff" />
</LinearLayout>
<include
layout="@layout/include_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset_less"
/>
<com.afollestad.nocknock.viewcomponents.interval.ValidationIntervalLayout
android:id="@+id/checkIntervalLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
/>
<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/NockText.SectionHeader"
/>
<Spinner
android:id="@+id/responseValidationMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<EditText
android:id="@+id/responseValidationSearchTerm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset_less"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="-4dp"
android:hint="@string/search_term"
android:visibility="gone"
tools:ignore="Autofill,TextFields"
style="@style/NockText.Body"
/>
<com.afollestad.nocknock.viewcomponents.js.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"
/>
<TextView
android:id="@+id/validationModeDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/content_inset_half"
android:lineSpacingMultiplier="1.2"
android:text="@string/validation_mode_status_desc"
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
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/last_check_result"
style="@style/NockText.SectionHeader"
/>
<TextView
android:id="@+id/textLastCheckResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
tools:text="Everything checks out!"
style="@style/NockText.Body"
/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/content_inset"
android:text="@string/next_check"
style="@style/NockText.SectionHeader"
/>
<TextView
android:id="@+id/textNextCheck"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
tools:text="In 2 hours"
style="@style/NockText.Body"
/>
</LinearLayout>
</ScrollView>
</LinearLayout>
<com.afollestad.nocknock.viewcomponents.LoadingIndicatorFrame
android:id="@+id/loadingProgress"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</FrameLayout>
</LinearLayout>

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,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<View
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset"
android:background="?dividerColor"
/>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/emptyText"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginBottom="@dimen/content_inset"
android:fontFamily="@font/lato_light"
android:gravity="center"
android:text="@string/no_sites_added"
android:textSize="@dimen/empty_text_size"
android:textStyle="italic"
/>

View file

@ -1,86 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:orientation="horizontal"
android:paddingBottom="@dimen/content_inset"
android:paddingBottom="@dimen/content_inset_less"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset"
>
android:paddingTop="@dimen/content_inset_less">
<com.afollestad.nocknock.viewcomponents.StatusImageView
android:id="@+id/iconStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="@dimen/content_inset"
android:scaleType="centerInside"
android:transitionName="status_image_view"
tools:ignore="ContentDescription"
/>
<com.afollestad.nocknock.views.StatusImageView
android:id="@+id/iconStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="@dimen/content_inset"
android:scaleType="centerInside"
android:transitionName="status_image_view"
tools:ignore="ContentDescription" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<RelativeLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
>
android:orientation="vertical">
<TextView
android:id="@+id/textName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_marginEnd="@dimen/content_inset_half"
android:layout_toStartOf="@+id/textInterval"
android:singleLine="true"
android:transitionName="site_name"
tools:text="Website Name"
style="@style/NockText.Title"
/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/textInterval"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
tools:text="1h"
style="@style/NockText.Caption"
/>
<TextView
android:id="@+id/textName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_marginEnd="@dimen/content_inset_half"
android:layout_toStartOf="@+id/textInterval"
android:fontFamily="sans-serif-medium"
android:singleLine="true"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/title_font_size"
android:transitionName="site_name"
tools:text="Website Name" />
</RelativeLayout>
<TextView
android:id="@+id/textInterval"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:fontFamily="sans-serif-light"
android:singleLine="true"
android:textColor="?android:textColorSecondary"
android:textSize="@dimen/caption_font_size"
tools:text="1h" />
<TextView
android:id="@+id/textUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:singleLine="true"
android:transitionName="site_url"
tools:text="https://yourwebsitehere.com"
style="@style/NockText.Body.Light"
/>
</RelativeLayout>
<TextView
android:id="@+id/textStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:singleLine="true"
tools:text="Everything checks out!"
style="@style/NockText.Body"
/>
<TextView
android:id="@+id/textUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:fontFamily="sans-serif"
android:singleLine="true"
android:textColor="?android:textColorSecondary"
android:textSize="@dimen/body_font_size"
android:transitionName="site_url"
tools:text="https://yourwebsitehere.com" />
</LinearLayout>
<TextView
android:id="@+id/textStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/list_text_spacing"
android:fontFamily="sans-serif"
android:singleLine="true"
android:textColor="?android:textColorSecondary"
android:textSize="@dimen/body_font_size"
tools:text="Everything checks out!" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/button_height"
android:fontFamily="sans-serif-light"
android:gravity="center_vertical|start"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/body_font_size" />

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/button_height"
android:fontFamily="sans-serif-light"
android:gravity="center_vertical|start"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
style="@style/NockText.Body"
/>
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/body_font_size" />

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"?>
<ImageView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/refresh_status"
android:src="@drawable/ic_action_refresh"
style="@android:style/Widget.ActionButton"
/>

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

@ -1,10 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/about"
android:title="@string/about"/>
<item
android:id="@+id/dark_mode"
android:checkable="true"
android:title="@string/dark_mode"/>
</menu>
<item
android:id="@+id/about"
android:title="@string/about" />
</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>
<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" />
</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>

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