Update all deps, re-write everything in Kotlin, use Dagger, etc.

This commit is contained in:
Aidan Follestad 2018-11-29 23:42:34 -08:00
parent 8a6cb18ae6
commit 56eb67d825
126 changed files with 3879 additions and 2886 deletions

1
.idea/.name generated
View file

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

22
.idea/compiler.xml generated
View file

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

View file

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

6
.idea/encodings.xml generated
View file

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

View file

@ -1,9 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="LoggerInitializedWithForeignClass" enabled="false" level="WARNING" enabled_by_default="false">
<option name="loggerClassName" value="org.apache.log4j.Logger,org.slf4j.LoggerFactory,org.apache.commons.logging.LogFactory,java.util.logging.Logger" />
<option name="loggerFactoryMethodName" value="getLogger,getLogger,getLog,getLogger" />
</inspection_tool>
</profile>
</component>

View file

@ -1,7 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="PROJECT_PROFILE" value="Project Default" />
<option name="USE_PROJECT_PROFILE" value="true" />
<version value="1.0" />
</settings>
</component>

View file

@ -1,72 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownProjectSettings">
<PreviewSettings splitEditorLayout="SPLIT" splitEditorPreview="PREVIEW" useGrayscaleRendering="false" zoomFactor="1.0" maxImageWidth="0" showGitHubPageIfSynced="false" allowBrowsingInPreview="false" synchronizePreviewPosition="true" highlightPreviewType="NONE" highlightFadeOut="5" highlightOnTyping="true" synchronizeSourcePosition="true" verticallyAlignSourceAndPreviewSyncPosition="true" showSearchHighlightsInPreview="false" showSelectionInPreview="true">
<PanelProvider>
<provider providerId="com.vladsch.idea.multimarkdown.editor.swing.html.panel" providerName="Default - Swing" />
</PanelProvider>
</PreviewSettings>
<ParserSettings gitHubSyntaxChange="false">
<PegdownExtensions>
<option name="ABBREVIATIONS" value="false" />
<option name="ANCHORLINKS" value="true" />
<option name="ASIDE" value="false" />
<option name="ATXHEADERSPACE" value="true" />
<option name="AUTOLINKS" value="true" />
<option name="DEFINITIONS" value="false" />
<option name="DEFINITION_BREAK_DOUBLE_BLANK_LINE" value="false" />
<option name="FENCED_CODE_BLOCKS" value="true" />
<option name="FOOTNOTES" value="false" />
<option name="HARDWRAPS" value="false" />
<option name="HTML_DEEP_PARSER" value="false" />
<option name="INSERTED" value="false" />
<option name="QUOTES" value="false" />
<option name="RELAXEDHRULES" value="true" />
<option name="SMARTS" value="false" />
<option name="STRIKETHROUGH" value="true" />
<option name="SUBSCRIPT" value="false" />
<option name="SUPERSCRIPT" value="false" />
<option name="SUPPRESS_HTML_BLOCKS" value="false" />
<option name="SUPPRESS_INLINE_HTML" value="false" />
<option name="TABLES" value="true" />
<option name="TASKLISTITEMS" value="true" />
<option name="TOC" value="false" />
<option name="WIKILINKS" value="true" />
</PegdownExtensions>
<ParserOptions>
<option name="COMMONMARK_LISTS" value="true" />
<option name="DUMMY" value="false" />
<option name="EMOJI_SHORTCUTS" value="true" />
<option name="FLEXMARK_FRONT_MATTER" value="false" />
<option name="GFM_LOOSE_BLANK_LINE_AFTER_ITEM_PARA" value="false" />
<option name="GFM_TABLE_RENDERING" value="true" />
<option name="GITBOOK_URL_ENCODING" value="false" />
<option name="GITHUB_EMOJI_URL" value="false" />
<option name="GITHUB_LISTS" value="false" />
<option name="GITHUB_WIKI_LINKS" value="true" />
<option name="JEKYLL_FRONT_MATTER" value="false" />
<option name="SIM_TOC_BLANK_LINE_SPACER" value="true" />
</ParserOptions>
</ParserSettings>
<HtmlSettings headerTopEnabled="false" headerBottomEnabled="false" bodyTopEnabled="false" bodyBottomEnabled="false" embedUrlContent="false" addPageHeader="true">
<GeneratorProvider>
<provider providerId="com.vladsch.idea.multimarkdown.editor.swing.html.generator" providerName="Default Swing HTML Generator" />
</GeneratorProvider>
<headerTop />
<headerBottom />
<bodyTop />
<bodyBottom />
</HtmlSettings>
<CssSettings previewScheme="UI_SCHEME" cssUri="" isCssUriEnabled="false" isCssTextEnabled="false" isDynamicPageWidth="true">
<StylesheetProvider>
<provider providerId="com.vladsch.idea.multimarkdown.editor.swing.html.css" providerName="Default Swing Stylesheet" />
</StylesheetProvider>
<ScriptProviders />
<cssText />
</CssSettings>
<HtmlExportSettings updateOnSave="false" parentDir="$ProjectFileDir$" targetDir="$ProjectFileDir$" cssDir="" scriptDir="" plainHtml="false" imageDir="" copyLinkedImages="false" imageUniquifyType="0" targetExt="" useTargetExt="false" noCssNoScripts="false" linkToExportedHtml="true" exportOnSettingsChange="true" regenerateOnProjectOpen="false" />
<LinkMapSettings>
<textMaps />
</LinkMapSettings>
</component>
</project>

View file

@ -1,3 +0,0 @@
<component name="MarkdownNavigator.ProfileManager">
<settings default="" pdf-export="" />
</component>

30
.idea/misc.xml generated
View file

@ -1,46 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<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="4">
<list size="7">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.Nullable" />
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nullable" />
<item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.Nullable" />
<item index="3" class="java.lang.String" itemvalue="android.support.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" />
</list>
</value>
</option>
<option name="myNotNulls">
<value>
<list size="4">
<list size="6">
<item index="0" class="java.lang.String" itemvalue="org.jetbrains.annotations.NotNull" />
<item index="1" class="java.lang.String" itemvalue="javax.annotation.Nonnull" />
<item index="2" class="java.lang.String" itemvalue="edu.umd.cs.findbugs.annotations.NonNull" />
<item index="3" class="java.lang.String" itemvalue="android.support.annotation.NonNull" />
<item index="4" class="java.lang.String" itemvalue="androidx.annotation.NonNull" />
<item index="5" class="java.lang.String" itemvalue="androidx.annotation.RecentlyNonNull" />
</list>
</value>
</option>
</component>
<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">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
</project>

9
.idea/modules.xml generated
View file

@ -2,11 +2,12 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/NockNock.iml" filepath="$PROJECT_DIR$/NockNock.iml" />
<module fileurl="file://$PROJECT_DIR$/app/app.iml" filepath="$PROJECT_DIR$/app/app.iml" />
<module fileurl="file://$PROJECT_DIR$/app/app.iml" filepath="$PROJECT_DIR$/app/app.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$/nock-nock.iml" filepath="$PROJECT_DIR$/nock-nock.iml" />
<module fileurl="file://$PROJECT_DIR$/notifications/notifications.iml" filepath="$PROJECT_DIR$/notifications/notifications.iml" />
<module fileurl="file://$PROJECT_DIR$/utilities/utilities.iml" filepath="$PROJECT_DIR$/utilities/utilities.iml" />
</modules>
</component>
</project>
</project>

View file

@ -4,8 +4,8 @@ android:
components:
- tools
- platform-tools
- build-tools-27.0.2
- android-27
- build-tools-28.0.3
- android-28
- extra-android-support
- extra-android-m2repository
- extra-google-m2repository
@ -19,4 +19,4 @@ android:
#- sys-img-x86-android-17
licenses:
- '.+'
- '.+'

Binary file not shown.

View file

@ -1,40 +1,38 @@
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 versions.compileSdk
buildToolsVersion versions.buildTools
defaultConfig {
applicationId "com.afollestad.nocknock"
minSdkVersion versions.minSdk
targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode
versionName versions.publishVersion
lintOptions {
abortOnError false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
defaultConfig {
applicationId "com.afollestad.nocknock"
minSdkVersion versions.minSdk
targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode
versionName versions.publishVersion
}
}
dependencies {
compile 'com.android.support:appcompat-v7:' + versions.supportLib
compile 'com.android.support:design:' + versions.supportLib
compile 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
compile 'com.afollestad.material-dialogs:commons:' + versions.materialDialogs
compile 'com.afollestad:bridge:' + versions.bridge
compile 'com.afollestad:inquiry:' + versions.inquiry
compile files('libs/rhino-1.7.7.1.jar')
}
implementation project(':data')
implementation project(':utilities')
implementation project(':engine')
implementation project(':notifications')
implementation 'androidx.appcompat:appcompat:' + versions.androidx
implementation 'androidx.recyclerview:recyclerview:' + versions.androidx
implementation 'com.google.android.material:material:' + versions.androidx
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
implementation 'com.google.dagger:dagger:' + versions.dagger
kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
}
apply from: '../spotless.gradle'

Binary file not shown.

View file

@ -1,17 +0,0 @@
# 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,60 +3,49 @@
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:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="AllowBackup,GoogleAppIndexingWarning">
<application
android:name=".App"
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">
<activity
android:name="com.afollestad.nocknock.ui.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.AddSiteActivity"
android:label="@string/add_site"
android:launchMode="singleTop"
android:theme="@style/AppTheme.Transparent"
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.ViewSiteActivity"
android:label="@string/view_site"
android:launchMode="singleTop"
android:theme="@style/AppTheme.Ink"
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="com.afollestad.nocknock.services.CheckService"
android:enabled="true"
android:label="Site Check Service" />
<service
android:name=".engine.statuscheck.CheckStatusJob"
android:label="@string/check_service_name"
android:permission="android.permission.BIND_JOB_SERVICE"/>
<receiver android:name=".receivers.BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
<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>
</manifest>
</manifest>

View file

@ -0,0 +1,56 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock
import android.app.Application
import android.app.NotificationManager
import android.app.job.JobScheduler
import android.content.Context
import com.afollestad.nocknock.di.AppComponent
import com.afollestad.nocknock.di.DaggerAppComponent
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
import com.afollestad.nocknock.ui.AddSiteActivity
import com.afollestad.nocknock.ui.MainActivity
import com.afollestad.nocknock.ui.ViewSiteActivity
import com.afollestad.nocknock.utilities.Injector
import okhttp3.OkHttpClient
/** @author Aidan Follestad (afollestad) */
class App : Application(), Injector {
lateinit var appComponent: AppComponent
override fun onCreate() {
super.onCreate()
val okHttpClient = OkHttpClient.Builder()
.addNetworkInterceptor { chain ->
val request = chain.request()
.newBuilder()
.addHeader("User-Agent", "com.afollestad.nocknock")
.build()
chain.proceed(request)
}
.build()
val jobScheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
appComponent = DaggerAppComponent.builder()
.application(this)
.okHttpClient(okHttpClient)
.jobScheduler(jobScheduler)
.notificationManager(notificationManager)
.build()
}
override fun injectInto(target: Any) = when (target) {
is MainActivity -> appComponent.inject(target)
is ViewSiteActivity -> appComponent.inject(target)
is AddSiteActivity -> appComponent.inject(target)
is CheckStatusJob -> appComponent.inject(target)
else -> throw IllegalStateException("Can't inject into $target")
}
}

View file

@ -1,169 +0,0 @@
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);
}
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);
}
private 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;
ServerVH(View itemView, ServerAdapter adapter) {
super(itemView);
iconStatus = itemView.findViewById(R.id.iconStatus);
textName = itemView.findViewById(R.id.textName);
textInterval = itemView.findViewById(R.id.textInterval);
textUrl = itemView.findViewById(R.id.textUrl);
textStatus = 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

@ -0,0 +1,137 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.textRes
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: ServerModel, longClick: Boolean) -> Unit
/** @author Aidan Follestad (afollestad) */
class ServerVH constructor(
itemView: View,
private val adapter: ServerAdapter
) : RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener {
init {
itemView.setOnClickListener(this)
itemView.setOnLongClickListener(this)
}
fun bind(model: ServerModel) {
itemView.textName.text = model.name
itemView.textUrl.text = model.url
itemView.iconStatus.setStatus(model.status)
val statusText = model.status.textRes()
if (statusText == 0) {
itemView.textStatus.text = model.reason
} else {
itemView.textStatus.setText(statusText)
}
itemView.textInterval.text = model.intervalText()
}
override fun onClick(view: View) {
adapter.performClick(adapterPosition, false)
}
override fun onLongClick(view: View): Boolean {
adapter.performClick(adapterPosition, true)
return false
}
}
/** @author Aidan Follestad (afollestad) */
class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<ServerVH>() {
private val models = mutableListOf<ServerModel>()
internal fun performClick(
index: Int,
longClick: Boolean
) = listener.invoke(models[index], longClick)
fun add(model: ServerModel) {
models.add(model)
notifyItemInserted(models.size - 1)
}
fun update(target: ServerModel) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
update(i, target)
break
}
}
}
private fun update(
index: Int,
model: ServerModel
) {
models[index] = model
notifyItemChanged(index)
}
fun remove(index: Int) {
models.removeAt(index)
notifyItemRemoved(index)
}
fun remove(target: ServerModel) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
remove(i)
break
}
}
}
fun set(newModels: List<ServerModel>) {
this.models.clear()
if (newModels.isEmpty()) {
return
}
this.models.addAll(newModels)
notifyDataSetChanged()
}
fun clear() {
models.clear()
notifyDataSetChanged()
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ServerVH {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_server, parent, false)
return ServerVH(v, this)
}
override fun onBindViewHolder(
holder: ServerVH,
position: Int
) {
val model = models[position]
holder.bind(model)
}
override fun getItemCount() = models.size
}

View file

@ -1,27 +0,0 @@
package com.afollestad.nocknock.api;
import static com.afollestad.nocknock.ui.MainActivity.SITES_TABLE_NAME;
import com.afollestad.inquiry.annotations.Column;
import com.afollestad.inquiry.annotations.Table;
import java.io.Serializable;
/** @author Aidan Follestad (afollestad) */
@Table(name = SITES_TABLE_NAME)
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

@ -1,18 +0,0 @@
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 static final int OK = 1;
public static final int WAITING = 2;
public static final int CHECKING = 3;
public static final int ERROR = 4;
@Retention(RetentionPolicy.SOURCE)
@IntDef({OK, WAITING, CHECKING, ERROR})
public @interface Enum {}
}

View file

@ -1,17 +0,0 @@
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 static final int STATUS_CODE = 1;
public static final int TERM_SEARCH = 2;
public static final int JAVASCRIPT = 3;
@Retention(RetentionPolicy.SOURCE)
@IntDef({STATUS_CODE, TERM_SEARCH, JAVASCRIPT})
public @interface Enum {}
}

View file

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

View file

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

View file

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

@ -0,0 +1,32 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
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.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 {
return MaterialDialog(activity!!)
.title(R.string.about)
.positiveButton(R.string.dismiss)
.message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
}
}

View file

@ -1,25 +0,0 @@
package com.afollestad.nocknock.receivers;
import android.annotation.SuppressLint;
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 {
@SuppressLint("VisibleForTests")
@Override
public void onReceive(Context context, Intent intent) {
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
final Inquiry inq = Inquiry.newInstance(context, MainActivity.DB_NAME).build(false);
ServerModel[] models = inq.select(ServerModel.class).all();
AlarmUtil.setSiteChecks(context, models);
inq.destroyInstance();
}
}
}

View file

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

@ -1,235 +0,0 @@
package com.afollestad.nocknock.services;
import android.annotation.SuppressLint;
import android.app.IntentService;
import android.app.Notification;
import android.app.PendingIntent;
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 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")
@SuppressLint("VisibleForTests")
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(ServerModel.class).values(new ServerModel[] {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)
.apply();
}
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)
.apply();
}
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

@ -1,275 +0,0 @@
package com.afollestad.nocknock.ui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.annotation.SuppressLint;
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;
@SuppressLint("SetTextI18n")
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_addsite);
rootLayout = findViewById(R.id.rootView);
nameTiLayout = findViewById(R.id.nameTiLayout);
inputName = findViewById(R.id.inputName);
urlTiLayout = findViewById(R.id.urlTiLayout);
inputUrl = findViewById(R.id.inputUrl);
textUrlWarning = findViewById(R.id.textUrlWarning);
inputInterval = findViewById(R.id.checkIntervalInput);
spinnerInterval = findViewById(R.id.checkIntervalSpinner);
responseValidationSpinner = findViewById(R.id.responseValidationMode);
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 = 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

@ -0,0 +1,278 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION
import android.net.Uri
import android.os.Bundle
import android.util.Patterns.WEB_URL
import android.view.View
import android.view.ViewAnimationUtils.createCircularReveal
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus.WAITING
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.utilities.ext.conceal
import com.afollestad.nocknock.utilities.ext.hide
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.onEnd
import com.afollestad.nocknock.utilities.ext.onItemSelected
import com.afollestad.nocknock.utilities.ext.onLayout
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.utilities.ext.show
import com.afollestad.nocknock.utilities.ext.showOrHide
import com.afollestad.nocknock.utilities.ext.textAsLong
import com.afollestad.nocknock.utilities.ext.trimmedText
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalInput
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalSpinner
import kotlinx.android.synthetic.main.activity_addsite.doneBtn
import kotlinx.android.synthetic.main.activity_addsite.inputName
import kotlinx.android.synthetic.main.activity_addsite.inputUrl
import kotlinx.android.synthetic.main.activity_addsite.nameTiLayout
import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_addsite.responseValidationScript
import kotlinx.android.synthetic.main.activity_addsite.responseValidationScriptInput
import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_addsite.rootView
import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.toolbar
import kotlinx.android.synthetic.main.activity_addsite.urlTiLayout
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.lang.System.currentTimeMillis
import javax.inject.Inject
import kotlin.math.max
private const val KEY_FAB_X = "fab_x"
private const val KEY_FAB_Y = "fab_y"
private const val KEY_FAB_SIZE = "fab_size"
/** @author Aidan Follestad (afollestad) */
fun MainActivity.intentToAdd(
x: Float,
y: Float,
size: Int
) = Intent(this, AddSiteActivity::class.java).apply {
putExtra(KEY_FAB_X, x)
putExtra(KEY_FAB_Y, y)
putExtra(KEY_FAB_SIZE, size)
addFlags(FLAG_ACTIVITY_NO_ANIMATION)
}
/** @author Aidan Follestad (afollestad) */
class AddSiteActivity : AppCompatActivity(), View.OnClickListener {
private var isClosing: Boolean = false
@Inject lateinit var serverModelStore: ServerModelStore
@Inject lateinit var checkStatusManager: CheckStatusManager
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_addsite)
toolbar.setNavigationOnClickListener { closeActivityWithReveal() }
if (savedInstanceState == null) {
rootView.conceal()
rootView.onLayout { circularRevealActivity() }
}
val intervalOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
resources.getStringArray(R.array.interval_options)
)
intervalOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
checkIntervalSpinner.adapter = intervalOptionsAdapter
inputUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
val inputStr = inputUrl.text
.toString()
.trim()
if (inputStr.isEmpty()) {
return@setOnFocusChangeListener
}
val uri = Uri.parse(inputStr)
if (uri.scheme == null) {
inputUrl.setText("http://$inputStr")
textUrlWarning.hide()
} else if ("http" != uri.scheme && "https" != uri.scheme) {
textUrlWarning.show()
textUrlWarning.setText(R.string.warning_http_url)
} else {
textUrlWarning.hide()
}
}
}
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
responseValidationMode.onItemSelected { pos ->
responseValidationSearchTerm.showOrHide(pos == 1)
responseValidationScript.showOrHide(pos == 2)
validationModeDescription.setText(
when (pos) {
0 -> R.string.validation_mode_status_desc
1 -> R.string.validation_mode_term_desc
2 -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unknown validation mode position: $pos")
}
)
}
doneBtn.setOnClickListener(this)
}
private fun closeActivityWithReveal() {
if (isClosing) {
return
}
isClosing = true
val fabSize = intent.getIntExtra(KEY_FAB_SIZE, toolbar!!.measuredHeight)
val defaultCx = rootView.measuredWidth / 2f
val cx =
intent.getFloatExtra(KEY_FAB_X, defaultCx).toInt() + fabSize / 2
val defaultCy = rootView.measuredHeight / 2f
val cy = (intent.getFloatExtra(KEY_FAB_Y, defaultCy).toInt() +
toolbar!!.measuredHeight +
fabSize / 2)
val initialRadius = max(cx, cy).toFloat()
createCircularReveal(rootView, cx, cy, initialRadius, 0f)
.apply {
duration = 300
interpolator = AccelerateInterpolator()
onEnd {
rootView.conceal()
finish()
overridePendingTransition(0, 0)
}
start()
}
}
private fun circularRevealActivity() {
val cx = rootView.measuredWidth / 2
val cy = rootView.measuredHeight / 2
val finalRadius = Math.max(cx, cy)
.toFloat()
val circularReveal = createCircularReveal(rootView, cx, cy, 0f, finalRadius)
.apply {
duration = 300
interpolator = DecelerateInterpolator()
}
rootView.show()
circularReveal.start()
}
// Done button
override fun onClick(view: View) {
isClosing = true
var model = ServerModel(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
status = WAITING,
validationMode = STATUS_CODE
)
if (model.name.isEmpty()) {
nameTiLayout.error = getString(R.string.please_enter_name)
isClosing = false
return
} else {
nameTiLayout.error = null
}
if (model.url.isEmpty()) {
urlTiLayout.error = getString(R.string.please_enter_url)
isClosing = false
return
} else {
urlTiLayout.error = null
if (!WEB_URL.matcher(model.url).find()) {
urlTiLayout.error = getString(R.string.please_enter_valid_url)
isClosing = false
return
} else {
val uri = Uri.parse(model.url)
if (uri.scheme == null) {
model = model.copy(url = "http://${model.url}")
}
}
}
val intervalValue = checkIntervalInput.textAsLong()
model = when (checkIntervalSpinner.selectedItemPosition) {
0 -> model.copy(checkInterval = intervalValue * (60 * 1000))
1 -> model.copy(checkInterval = intervalValue * (60 * 60 * 1000))
2 -> model.copy(checkInterval = intervalValue * (60 * 60 * 24 * 1000))
else -> model.copy(checkInterval = intervalValue * (60 * 60 * 24 * 7 * 1000))
}
model = model.copy(lastCheck = currentTimeMillis() - model.checkInterval)
when (responseValidationMode.selectedItemPosition) {
0 -> {
model = model.copy(validationMode = STATUS_CODE, validationContent = null)
}
1 -> {
model = model.copy(
validationMode = TERM_SEARCH,
validationContent = responseValidationSearchTerm.trimmedText()
)
}
2 -> {
model = model.copy(
validationMode = JAVASCRIPT,
validationContent = responseValidationScriptInput.trimmedText()
)
}
}
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
val storedModel = async(IO) { serverModelStore.put(model) }.await()
checkStatusManager.cancelCheck(storedModel)
checkStatusManager.scheduleCheck(storedModel, rightNow = true)
setResult(RESULT_OK)
finish()
overridePendingTransition(R.anim.fade_out, R.anim.fade_out)
}
}
}
override fun onBackPressed() = closeActivityWithReveal()
}

View file

@ -1,319 +0,0 @@
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.dialogs.AboutDialog;
import com.afollestad.nocknock.services.CheckService;
import com.afollestad.nocknock.util.AlarmUtil;
import com.afollestad.nocknock.util.MathUtil;
@SuppressLint("VisibleForTests")
public class MainActivity extends AppCompatActivity
implements SwipeRefreshLayout.OnRefreshListener,
View.OnClickListener,
ServerAdapter.ClickListener {
private static final int ADD_SITE_RQ = 6969;
private static final int VIEW_SITE_RQ = 6923;
public static final String DB_NAME = "nock_nock";
public static final 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 = findViewById(R.id.emptyText);
mList = findViewById(R.id.list);
mList.setLayoutManager(new LinearLayoutManager(this));
mList.setAdapter(mAdapter);
mList.addItemDecoration(
new android.support.v7.widget.DividerItemDecoration(
this, android.support.v7.widget.DividerItemDecoration.VERTICAL));
mRefreshLayout = 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 = findViewById(R.id.fab);
mFab.setOnClickListener(this);
Inquiry.newInstance(this, DB_NAME).build();
Bridge.config().defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)");
}
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).apply();
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(new ServerModel[] {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.delete(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));
}
@SuppressLint("RestrictedApi")
@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

@ -0,0 +1,260 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.ui
import android.animation.ObjectAnimator
import android.animation.ObjectAnimator.ofFloat
import android.annotation.SuppressLint
import android.app.ActivityOptions.makeSceneTransitionAnimation
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.View.X
import android.view.View.Y
import android.view.animation.PathInterpolator
import androidx.appcompat.app.AppCompatActivity
import androidx.core.text.HtmlCompat
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItems
import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.ServerAdapter
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.dialogs.AboutDialog
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.onEnd
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.utilities.ext.show
import com.afollestad.nocknock.utilities.ext.showOrHide
import com.afollestad.nocknock.utilities.util.MathUtil.bezierCurve
import kotlinx.android.synthetic.main.activity_main.emptyText
import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list
import kotlinx.android.synthetic.main.activity_main.rootView
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject
/** @author Aidan Follestad (afollestad) */
class MainActivity : AppCompatActivity(), View.OnClickListener {
companion object {
private const val ADD_SITE_RQ = 6969
private const val VIEW_SITE_RQ = 6923
private const val REVEAL_DURATION = 250L
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("MainActivity", message)
}
}
}
private var fabAnimator: ObjectAnimator? = null
private var originalFabX: Float = 0.toFloat()
private var originalFabY: Float = 0.toFloat()
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) {
log("Received broadcast ${intent.action}")
when (intent.action) {
ACTION_STATUS_UPDATE -> {
val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return
list.post { adapter.update(model) }
}
else -> throw IllegalStateException("Unexpected intent: ${intent.action}")
}
}
}
@Inject lateinit var serverModelStore: ServerModelStore
@Inject lateinit var notificationManager: NockNotificationManager
@Inject lateinit var checkStatusManager: CheckStatusManager
private lateinit var adapter: ServerAdapter
@SuppressLint("CommitPrefEdits")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_main)
adapter = ServerAdapter(this::onSiteSelected)
list.layoutManager = LinearLayoutManager(this)
list.adapter = adapter
list.addItemDecoration(DividerItemDecoration(this, VERTICAL))
fab.setOnClickListener(this)
}
override fun onResume() {
super.onResume()
notificationManager.setIsAppOpen(true)
val filter = IntentFilter().apply {
addAction(ACTION_STATUS_UPDATE)
}
safeRegisterReceiver(intentReceiver, filter)
refreshModels()
}
override fun onPause() {
super.onPause()
notificationManager.setIsAppOpen(false)
safeUnregisterReceiver(intentReceiver)
}
private fun refreshModels() {
adapter.clear()
emptyText.show()
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
val models = async(IO) { serverModelStore.get() }.await()
adapter.set(models)
emptyText.showOrHide(adapter.itemCount == 0)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.about) {
AboutDialog.show(this)
return true
}
return super.onOptionsItemSelected(item)
}
// FAB clicked
override fun onClick(view: View) {
originalFabX = fab.x
originalFabY = fab.y
fabAnimator?.cancel()
fabAnimator = ofFloat(view, X, Y, bezierCurve(fab, list))
.apply {
interpolator = PathInterpolator(.5f, .5f)
duration = REVEAL_DURATION
onEnd {
startActivityForResult(
intentToAdd(originalFabX, originalFabY, fab.measuredWidth),
ADD_SITE_RQ
)
fab.postDelayed(
{
fab.x = originalFabX
fab.y = originalFabY
},
REVEAL_DURATION * 2
)
}
start()
}
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK) {
refreshModels()
}
}
private fun onSiteSelected(
model: ServerModel,
longClick: Boolean
) {
if (longClick) {
MaterialDialog(this).show {
title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
0 -> {
checkStatusManager.cancelCheck(model)
checkStatusManager.scheduleCheck(model)
}
1 -> maybeRemoveSite(model) {
adapter.remove(i)
emptyText.showOrHide(adapter.itemCount == 0)
}
else -> throw IllegalStateException("Unexpected index: $i")
}
}
negativeButton(android.R.string.cancel)
}
return
}
startActivityForResult(
intentToView(model),
VIEW_SITE_RQ,
makeSceneTransitionAnimation(this).toBundle()
)
}
private fun maybeRemoveSite(
model: ServerModel,
onRemoved: (() -> Unit)?
) {
MaterialDialog(this).show {
title(R.string.remove_site)
message(
text = HtmlCompat.fromHtml(
context.getString(R.string.remove_site_prompt, model.name), FROM_HTML_MODE_LEGACY
)
)
positiveButton(R.string.remove) {
checkStatusManager.cancelCheck(model)
notificationManager.cancelStatusNotifications()
performRemoveSite(model, onRemoved)
}
negativeButton(android.R.string.cancel)
}
}
private fun performRemoveSite(
model: ServerModel,
onRemoved: (() -> Unit)?
) {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
async(IO) { serverModelStore.delete(model) }.await()
onRemoved?.invoke()
}
}
}
}

View file

@ -1,359 +0,0 @@
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();
}
}
};
@SuppressLint("SetTextI18n")
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_viewsite);
final Toolbar toolbar = findViewById(R.id.toolbar);
toolbar.setNavigationOnClickListener(view -> finish());
toolbar.inflateMenu(R.menu.menu_viewsite);
toolbar.setOnMenuItemClickListener(this);
iconStatus = findViewById(R.id.iconStatus);
inputName = findViewById(R.id.inputName);
inputUrl = findViewById(R.id.inputUrl);
textUrlWarning = findViewById(R.id.textUrlWarning);
inputCheckInterval = findViewById(R.id.checkIntervalInput);
checkIntervalSpinner = findViewById(R.id.checkIntervalSpinner);
textLastCheckResult = findViewById(R.id.textLastCheckResult);
textNextCheck = findViewById(R.id.textNextCheck);
responseValidationSpinner = 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 = 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();
}
}
@SuppressLint("VisibleForTests")
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(ServerModel.class).values(new ServerModel[] {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

@ -0,0 +1,416 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
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.util.Log
import android.util.Patterns.WEB_URL
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.text.HtmlCompat
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus.WAITING
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.textRes
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.WEEK
import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.utilities.ext.hide
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.isHttpOrHttps
import com.afollestad.nocknock.utilities.ext.onItemSelected
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.utilities.ext.show
import com.afollestad.nocknock.utilities.ext.showOrHide
import com.afollestad.nocknock.utilities.ext.textAsLong
import com.afollestad.nocknock.utilities.ext.trimmedText
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalInput
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalSpinner
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
import kotlinx.android.synthetic.main.activity_viewsite.inputName
import kotlinx.android.synthetic.main.activity_viewsite.inputUrl
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationScript
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationScriptInput
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_viewsite.rootView
import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult
import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck
import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_viewsite.toolbar
import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.lang.System.currentTimeMillis
import javax.inject.Inject
import kotlin.math.ceil
private const val KEY_VIEW_MODEL = "site_model"
/** @author Aidan Follestad (afViewSiteActivityollestad) */
fun MainActivity.intentToView(model: ServerModel) =
Intent(this, ViewSiteActivity::class.java).apply {
putExtra(KEY_VIEW_MODEL, model)
}
/** @author Aidan Follestad (afollestad) */
class ViewSiteActivity : AppCompatActivity(),
View.OnClickListener,
Toolbar.OnMenuItemClickListener {
companion object {
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("ViewSiteActivity", message)
}
}
}
private lateinit var currentModel: ServerModel
@Inject lateinit var serverModelStore: ServerModelStore
@Inject lateinit var notificationManager: NockNotificationManager
@Inject lateinit var checkStatusManager: CheckStatusManager
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) {
log("Received broadcast ${intent.action}")
val model = intent.getSerializableExtra(KEY_VIEW_MODEL) as? ServerModel
if (model != null) {
this@ViewSiteActivity.currentModel = model
update()
}
}
}
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_viewsite)
toolbar.run {
setNavigationOnClickListener { finish() }
inflateMenu(R.menu.menu_viewsite)
setOnMenuItemClickListener(this@ViewSiteActivity)
}
val intervalOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
resources.getStringArray(R.array.interval_options)
)
intervalOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
checkIntervalSpinner.adapter = intervalOptionsAdapter
inputUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
val inputStr = inputUrl.text
.toString()
.trim()
if (inputStr.isEmpty()) {
return@setOnFocusChangeListener
}
val uri = Uri.parse(inputStr)
if (uri.scheme == null) {
inputUrl.setText("http://$inputStr")
textUrlWarning.hide()
} else if (!uri.isHttpOrHttps()) {
textUrlWarning.show()
textUrlWarning.setText(R.string.warning_http_url)
} else {
textUrlWarning.hide()
}
}
}
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
responseValidationMode.onItemSelected { pos ->
responseValidationSearchTerm.showOrHide(pos == 1)
responseValidationScript.showOrHide(pos == 2)
validationModeDescription.setText(
when (pos) {
0 -> R.string.validation_mode_status_desc
1 -> R.string.validation_mode_term_desc
2 -> R.string.validation_mode_javascript_desc
else -> throw IllegalStateException("Unexpected position: $pos")
}
)
}
currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
update()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) {
currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel
update()
}
}
@SuppressLint("SetTextI18n")
private fun update() = with(currentModel) {
iconStatus.setStatus(this.status)
inputName.setText(this.name)
inputUrl.setText(this.url)
if (this.lastCheck == 0L) {
textLastCheckResult.setText(R.string.none)
} else {
val statusText = this.status.textRes()
textLastCheckResult.text = if (statusText == 0) {
this.reason
} else {
getString(statusText)
}
}
if (this.checkInterval == 0L) {
textNextCheck.setText(R.string.none_turned_off)
checkIntervalInput.setText("")
checkIntervalSpinner.setSelection(0)
} else {
var lastCheck = this.lastCheck
if (lastCheck == 0L) {
lastCheck = currentTimeMillis()
}
textNextCheck.text = (lastCheck + this.checkInterval).formatDate()
when {
this.checkInterval >= WEEK -> {
checkIntervalInput.setText(
ceil((this.checkInterval.toFloat() / WEEK).toDouble()).toInt().toString()
)
checkIntervalSpinner.setSelection(3)
}
this.checkInterval >= DAY -> {
checkIntervalInput.setText(
ceil((this.checkInterval.toFloat() / DAY.toFloat()).toDouble()).toInt().toString()
)
checkIntervalSpinner.setSelection(2)
}
this.checkInterval >= HOUR -> {
checkIntervalInput.setText(
ceil((this.checkInterval.toFloat() / HOUR.toFloat()).toDouble()).toInt().toString()
)
checkIntervalSpinner.setSelection(1)
}
this.checkInterval >= MINUTE -> {
checkIntervalInput.setText(
ceil((this.checkInterval.toFloat() / MINUTE.toFloat()).toDouble()).toInt().toString()
)
checkIntervalSpinner.setSelection(0)
}
else -> {
checkIntervalInput.setText("0")
checkIntervalSpinner.setSelection(0)
}
}
}
responseValidationMode.setSelection(validationMode.value - 1)
when (this.validationMode) {
TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "")
JAVASCRIPT -> responseValidationScriptInput.setText(this.validationContent ?: "")
else -> {
responseValidationSearchTerm.setText("")
responseValidationScriptInput.setText("")
}
}
doneBtn.setOnClickListener(this@ViewSiteActivity)
}
override fun onResume() {
super.onResume()
try {
val filter = IntentFilter()
filter.addAction(ACTION_STATUS_UPDATE)
// filter.addAction(CheckService.ACTION_JOB_RUNNING);
registerReceiver(intentReceiver, filter)
} catch (t: Throwable) {
t.printStackTrace()
}
}
override fun onPause() {
super.onPause()
try {
unregisterReceiver(intentReceiver)
} catch (t: Throwable) {
t.printStackTrace()
}
}
private fun updateModelFromInput(withValidation: Boolean) {
currentModel = currentModel.copy(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
status = WAITING
)
if (withValidation && currentModel.name.isEmpty()) {
inputName.error = getString(R.string.please_enter_name)
return
} else {
inputName.error = null
}
if (withValidation && currentModel.url.isEmpty()) {
inputUrl.error = getString(R.string.please_enter_url)
return
} else {
inputUrl.error = null
if (withValidation && !WEB_URL.matcher(currentModel.url).find()) {
inputUrl.error = getString(R.string.please_enter_valid_url)
return
} else {
val uri = Uri.parse(currentModel.url)
if (uri.scheme == null) {
currentModel = currentModel.copy(url = "http://${currentModel.url}")
}
}
}
val intervalValue = checkIntervalInput.textAsLong()
currentModel = when (checkIntervalSpinner.selectedItemPosition) {
0 -> currentModel.copy(checkInterval = intervalValue * (60 * 1000))
1 -> currentModel.copy(checkInterval = intervalValue * (60 * 60 * 1000))
2 -> currentModel.copy(checkInterval = intervalValue * (60 * 60 * 24 * 1000))
else -> currentModel.copy(checkInterval = intervalValue * (60 * 60 * 24 * 7 * 1000))
}
currentModel = currentModel.copy(
lastCheck = currentTimeMillis() - currentModel.checkInterval
)
when (responseValidationMode.selectedItemPosition) {
0 -> {
currentModel = currentModel.copy(
validationMode = STATUS_CODE,
validationContent = null
)
}
1 -> {
currentModel = currentModel.copy(
validationMode = TERM_SEARCH,
validationContent = responseValidationSearchTerm.trimmedText()
)
}
2 -> {
currentModel = currentModel.copy(
validationMode = JAVASCRIPT,
validationContent = responseValidationScriptInput.trimmedText()
)
}
}
}
// Save button
override fun onClick(view: View) {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
updateModelFromInput(true)
async(IO) { serverModelStore.update(currentModel) }.await()
setResult(RESULT_OK)
finish()
}
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.refresh -> {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
updateModelFromInput(false)
async(IO) { serverModelStore.update(currentModel) }.await()
checkStatusManager.cancelCheck(currentModel)
checkStatusManager.scheduleCheck(currentModel, rightNow = true)
}
}
return true
}
R.id.remove -> {
maybeRemoveSite(currentModel) { finish() }
return true
}
}
return false
}
private fun maybeRemoveSite(
model: ServerModel,
onRemoved: (() -> Unit)?
) {
MaterialDialog(this).show {
title(R.string.remove_site)
message(
text = HtmlCompat.fromHtml(
context.getString(R.string.remove_site_prompt, model.name),
HtmlCompat.FROM_HTML_MODE_LEGACY
)
)
positiveButton(R.string.remove) {
checkStatusManager.cancelCheck(model)
notificationManager.cancelStatusNotifications()
performRemoveSite(model, onRemoved)
}
negativeButton(android.R.string.cancel)
}
}
private fun performRemoveSite(
model: ServerModel,
onRemoved: (() -> Unit)?
) {
rootView.scopeWhileAttached(Main) {
launch(coroutineContext) {
async(IO) { serverModelStore.delete(model) }.await()
onRemoved?.invoke()
}
}
}
}

View file

@ -1,61 +0,0 @@
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 static final 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

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

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

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

@ -1,30 +0,0 @@
package com.afollestad.nocknock.util;
/** @author Aidan Follestad (afollestad) */
public class TimeUtil {
public static final long SECOND = 1000;
public static final long MINUTE = SECOND * 60;
public static final long HOUR = MINUTE * 60;
public static final long DAY = HOUR * 24;
public static final long WEEK = DAY * 7;
public static final 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

@ -1,44 +0,0 @@
package com.afollestad.nocknock.views;
import android.content.Context;
import android.support.v7.widget.AppCompatImageView;
import android.util.AttributeSet;
import com.afollestad.nocknock.R;
import com.afollestad.nocknock.api.ServerStatus;
/** @author Aidan Follestad (afollestad) */
public class StatusImageView extends AppCompatImageView {
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,42 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.views
import android.content.Context
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerStatus
import com.afollestad.nocknock.data.ServerStatus.CHECKING
import com.afollestad.nocknock.data.ServerStatus.ERROR
import com.afollestad.nocknock.data.ServerStatus.OK
import com.afollestad.nocknock.data.ServerStatus.WAITING
/** @author Aidan Follestad (afollestad) */
class StatusImageView(
context: Context,
attrs: AttributeSet? = null
) : AppCompatImageView(context, attrs) {
init {
setStatus(OK)
}
fun setStatus(status: ServerStatus) = when (status) {
CHECKING, WAITING -> {
setImageResource(R.drawable.status_progress)
setBackgroundResource(R.drawable.yellow_circle)
}
ERROR -> {
setImageResource(R.drawable.status_error)
setBackgroundResource(R.drawable.red_circle)
}
OK -> {
setImageResource(R.drawable.status_ok)
setBackgroundResource(R.drawable.green_circle)
}
}
}

View file

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

View file

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

View file

@ -2,11 +2,11 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/md_green" />
<solid android:color="@color/md_green"/>
<stroke android:color="#424242" />
<stroke android:color="#424242"/>
<size
android:width="@dimen/list_circle_size"
android:height="@dimen/list_circle_size" />
</shape>
<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="#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" />
<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="#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>
<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="#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>
<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,9 +0,0 @@
<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

@ -2,11 +2,11 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/md_red" />
<solid android:color="@color/md_red"/>
<stroke android:color="#424242"/>
<stroke android:color="#424242"/>
<size
android:width="@dimen/list_circle_size"
android:height="@dimen/list_circle_size" />
</shape>
<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="#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>
<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

@ -3,7 +3,7 @@
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>
<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

@ -3,7 +3,7 @@
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>
<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

@ -2,11 +2,11 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/md_yellow" />
<solid android:color="@color/md_yellow"/>
<stroke android:color="#424242" />
<stroke android:color="#424242"/>
<size
android:width="@dimen/list_circle_size"
android:height="@dimen/list_circle_size" />
</shape>
<size
android:width="@dimen/list_circle_size"
android:height="@dimen/list_circle_size"/>
</shape>

View file

@ -1,242 +1,270 @@
<?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: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">
android:orientation="vertical"
>
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
<androidx.appcompat.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:layout_width="match_parent"
android:layout_height="match_parent"
>
<LinearLayout
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" />
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/nameTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset"
>
<LinearLayout
<com.google.android.material.textfield.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"
/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/urlTiLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="-4dp"
android:layout_marginRight="-4dp"
android:layout_marginTop="@dimen/content_inset_half"
>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android: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"
/>
</com.google.android.material.textfield.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"
tools:ignore="Autofill"
/>
<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"
tools:ignore="UnusedAttribute"
>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset">
>
<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">
<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"
/>
<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" />
<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="Autofill,LabelFor,RtlSymmetry"
/>
</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" />
<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>
</ScrollView>
</HorizontalScrollView>
</LinearLayout>
<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>
</ScrollView>
</LinearLayout>

View file

@ -2,104 +2,44 @@
<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.MainActivity">
tools:context=".ui.MainActivity"
>
<android.support.v4.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
/>
<android.support.v7.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
<TextView
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="sans-serif-medium"
android:gravity="center"
android:text="@string/no_sites_added"
android:textSize="@dimen/empty_text_size"
android:textStyle="italic"
/>
</android.support.v4.widget.SwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android: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"
/>
<TextView
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="sans-serif-light"
android:gravity="center"
android:text="@string/no_sites_added"
android:textSize="@dimen/title_font_size"
android:textStyle="italic" />
<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" />
<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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="2">
<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" />
<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>
</FrameLayout>

View file

@ -6,290 +6,324 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?colorPrimary"
android:orientation="vertical">
android:orientation="vertical"
>
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
<androidx.appcompat.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:layout_width="match_parent"
android:layout_height="match_parent"
>
<LinearLayout
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" />
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half"
>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
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,UnusedAttribute"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/content_inset"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_half">
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<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"
tools:ignore="UnusedAttribute"
/>
<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" />
<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"
tools:ignore="UnusedAttribute"
/>
<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/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_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" />
<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>
</ScrollView>
</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/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_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"
tools:ignore="UnusedAttribute"
>
<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="Autofill,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>
</ScrollView>
</LinearLayout>

View file

@ -1,5 +1,6 @@
<?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"
@ -8,80 +9,88 @@
android:paddingBottom="@dimen/content_inset_less"
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:paddingTop="@dimen/content_inset_less">
android:paddingTop="@dimen/content_inset_less"
>
<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" />
<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
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="horizontal"
>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<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"
/>
<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" />
<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/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" />
</RelativeLayout>
</RelativeLayout>
<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"
/>
<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" />
<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!"
/>
<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>
</LinearLayout>

View file

@ -1,8 +1,9 @@
<?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:textColor="?android:textColorPrimary"
android:textSize="@dimen/body_font_size" />
android:textSize="@dimen/body_font_size" />

View file

@ -1,5 +1,6 @@
<?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"
@ -7,4 +8,5 @@
android:paddingLeft="@dimen/content_inset"
android:paddingRight="@dimen/content_inset"
android:textColor="?android:textColorPrimary"
android:textSize="@dimen/body_font_size" />
android:textSize="@dimen/body_font_size"
/>

View file

@ -1,8 +1,6 @@
<?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" />
</menu>
<item
android:id="@+id/about"
android:title="@string/about"/>
</menu>

View file

@ -2,16 +2,16 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/refresh"
android:icon="@drawable/ic_action_refresh"
android:title="@string/refresh_status"
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/remove"
android:icon="@drawable/ic_action_delete"
android:title="@string/remove_site"
app:showAsAction="ifRoom"/>
</menu>
</menu>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="interval_options">
<item>Minute(s)</item>
<item>Hour(s)</item>
<item>Day(s)</item>
<item>Week(s)</item>
</string-array>
<string-array name="site_long_options" translatable="false">
<item>@string/refresh_status</item>
<item>@string/remove_site</item>
</string-array>
<string-array name="response_validation_options">
<item>Status Code</item>
<item>Search Term</item>
<item>JavaScript Evaluation</item>
</string-array>
</resources>

View file

@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#455A64</color>
<color name="colorPrimaryDark">#37474F</color>
<color name="colorAccent">#FF6E40</color>
<color name="colorPrimary">#455A64</color>
<color name="colorPrimaryDark">#37474F</color>
<color name="colorAccent">#FF6E40</color>
<color name="dividerColor">#EEEEEE</color>
<color name="dividerColor">#EEEEEE</color>
<color name="md_red">#E53935</color>
<color name="md_yellow">#FDD835</color>
<color name="md_green">#43A047</color>
<color name="md_red">#E53935</color>
<color name="md_yellow">#FDD835</color>
<color name="md_green">#43A047</color>
<color name="dividerColorDark">#37474F</color>
<color name="dividerColorDark">#37474F</color>
</resources>

View file

@ -1,23 +1,21 @@
<resources>
<dimen name="headline_font_size">24sp</dimen>
<dimen name="title_font_size">20sp</dimen>
<dimen name="medium_text_size">16sp</dimen>
<dimen name="body_font_size">14sp</dimen>
<dimen name="caption_font_size">12sp</dimen>
<dimen name="title_font_size">20sp</dimen>
<dimen name="medium_text_size">16sp</dimen>
<dimen name="body_font_size">14sp</dimen>
<dimen name="caption_font_size">12sp</dimen>
<dimen name="empty_text_size">26sp</dimen>
<dimen name="content_inset_half">8dp</dimen>
<dimen name="content_inset_less">12dp</dimen>
<dimen name="content_inset">16dp</dimen>
<dimen name="content_inset_more">24dp</dimen>
<dimen name="content_inset_double">32dp</dimen>
<dimen name="content_inset_half">8dp</dimen>
<dimen name="content_inset_less">12dp</dimen>
<dimen name="content_inset">16dp</dimen>
<dimen name="content_inset_more">24dp</dimen>
<dimen name="list_circle_size">42dp</dimen>
<dimen name="list_text_spacing">4dp</dimen>
<dimen name="fab_elevation">4dp</dimen>
<dimen name="fab_elevation_pressed">8dp</dimen>
<dimen name="button_height">52dp</dimen>
<dimen name="tutorial_button_width">300dp</dimen>
<dimen name="code_font_size">14sp</dimen>
<dimen name="list_circle_size">42dp</dimen>
<dimen name="list_text_spacing">4dp</dimen>
<dimen name="fab_elevation">4dp</dimen>
<dimen name="fab_elevation_pressed">8dp</dimen>
<dimen name="button_height">52dp</dimen>
<dimen name="code_font_size">14sp</dimen>
</resources>
</resources>

View file

@ -1,81 +1,55 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" tools:ignore="Typos">Nock Nock (BETA)</string>
<string name="app_name" tools:ignore="Typos">Nock Nock (BETA)</string>
<string name="everything_checks_out">Everything checks out!</string>
<string name="something_wrong">Something\'s wrong! Tap for details.</string>
<string name="checking_status">Checking status…</string>
<string name="waiting">Waiting…</string>
<string name="no_sites_added">No sites added!</string>
<string name="no_sites_added">No sites added!</string>
<string name="about">About</string>
<string name="about_body"><![CDATA[
<string name="about">About</string>
<string name="about_body"><![CDATA[
<b>Nock Nock</b>, a simple app designed by <b>Aidan Follestad</b>.<br/>
<a href=\'https://aidanfollestad.com\'>Website</a>&nbsp;&nbsp;
<a href=\'https://twitter.com/afollestad\'>Twitter</a>&nbsp;&nbsp;
<a href=\'https://google.com/+AidanFollestad\'>Google+</a>&nbsp;&nbsp;
<a href=\'https://github.com/afollestad\'>GitHub</a>&nbsp;&nbsp;
<a href=\'https://www.linkedin.com/in/afollestad\'>LinkedIn</a>
<br/><br/><i>Nock Nock is open source! Check out the <a href=\'https://github.com/afollestad/nock-nock\'>GitHub page</a>!</i>
<br/>Icon by <a href=\'https://plus.google.com/+KevinAguilarC\'>Kevin Aguilar</a> of <b>221 Pixels</b>.
]]></string>
<string name="dismiss">Dismiss</string>
<string name="add_site">Add Site</string>
<string name="site_name">Site Name</string>
<string name="site_url">Site URL</string>
<string name="check_interval">Check Interval</string>
<string name="done">Done</string>
<string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</string>
<string name="please_enter_valid_url">Please enter a valid URL.</string>
<string name="timeout">Request timed out! Your server is probably down.</string>
<string name="options">Options</string>
<string name="already_checking_sites">Already checking sites!</string>
<string name="remove_site">Remove Site</string>
<string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string>
<string name="remove">Remove</string>
<string name="save">Save</string>
<string name="view_site">View Site</string>
<string name="last_check_result">Last Check Result</string>
<string name="next_check">Next Check</string>
<string name="none_turned_off">None (turned off)</string>
<string name="none">None</string>
<string name="dismiss">Dismiss</string>
<string name="add_site">Add Site</string>
<string name="site_name">Site Name</string>
<string name="site_url">Site URL</string>
<string name="check_interval">Check Interval</string>
<string name="done">Done</string>
<string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</string>
<string name="please_enter_valid_url">Please enter a valid URL.</string>
<string name="refresh_status">Refresh Status</string>
<string name="swipe_refresh_hint">Drag the list down to manually refresh site statuses! Otherwise, they will be updated automatically in the background on chosen intervals.</string>
<string name="understood">Understood!</string>
<string name="options">Options</string>
<string name="already_checking_sites">Already checking sites!</string>
<string name="remove_site">Remove Site</string>
<string name="remove_site_prompt"><![CDATA[Remove <b>%1$s</b> from your sites?]]></string>
<string name="remove">Remove</string>
<string name="save">Save</string>
<string name="view_site">View Site</string>
<string name="last_check_result">Last Check Result</string>
<string name="next_check">Next Check</string>
<string name="none_turned_off">None (turned off)</string>
<string name="none">None</string>
<string name="warning_http_url">
<string name="refresh_status">Refresh Status</string>
<string name="warning_http_url">
Warning: this app checks for server availability with HTTP requests. It\'s recommended that you use an HTTP URL.
</string>
<string name="default_js">var responseObj = JSON.parse(response);\nreturn responseObj.success === true;</string>
<string name="function_declaration">function validate(response) {</string>
<string name="function_end">}</string>
<string name="response_validation_mode">Response Validation Mode</string>
<string name="search_term">Search term…</string>
<string name="default_js">var responseObj = JSON.parse(response);\nreturn responseObj.success === true;</string>
<string name="function_declaration">function validate(response) {</string>
<string name="function_end">}</string>
<string name="response_validation_mode">Response Validation Mode</string>
<string name="search_term">Search term…</string>
<string name="validation_mode_status_desc">The HTTP status code is checked. If it\'s a successful status code, the site passes the check.</string>
<string name="validation_mode_term_desc">The status code check is done first. If it\'s successful, the response body is checked. If it contains your search term, the site passes the check.</string>
<string name="validation_mode_javascript_desc">The status code check is done first. If it\'s successful, the response body is passed to the JavaScript function above. If the function returns true, the site passes the check. Throw an exception to pass custom error messages to Nock Nock.</string>
<string-array name="interval_options">
<item>Minute(s)</item>
<item>Hour(s)</item>
<item>Day(s)</item>
<item>Week(s)</item>
</string-array>
<string-array name="site_long_options" translatable="false">
<item>@string/refresh_status</item>
<item>@string/remove_site</item>
</string-array>
<string-array name="response_validation_options">
<item>Status Code</item>
<item>Search Term</item>
<item>JavaScript Evaluation</item>
</string-array>
<string name="validation_mode_status_desc">The HTTP status code is checked. If it\'s a successful status code, the site passes the check.</string>
<string name="validation_mode_term_desc">The status code check is done first. If it\'s successful, the response body is checked. If it contains your search term, the site passes the check.</string>
<string name="validation_mode_javascript_desc">The status code check is done first. If it\'s successful, the response body is passed to the JavaScript function above. If the function returns true, the site passes the check. Throw an exception to pass custom error messages to Nock Nock.</string>
</resources>

View file

@ -1,35 +1,35 @@
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorPrimaryDark</item>
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorPrimaryDark</item>
<item name="android:listDivider">@drawable/divider</item>
<item name="android:listDivider">@drawable/divider</item>
<item name="android:textColorPrimary">#212121</item>
<item name="android:textColorSecondary">#727272</item>
</style>
<item name="android:textColorPrimary">#212121</item>
<item name="android:textColorSecondary">#727272</item>
</style>
<style name="AppTheme.Ink" parent="Theme.AppCompat.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorPrimaryDark</item>
<style name="AppTheme.Ink" parent="Theme.AppCompat.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorPrimaryDark</item>
<item name="android:listDivider">@drawable/divider</item>
</style>
<item name="android:listDivider">@drawable/divider</item>
</style>
<style name="AppTheme.Transparent" parent="AppTheme.Ink">
<item name="android:windowIsTranslucent">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
<style name="AppTheme.Transparent" parent="AppTheme.Ink">
<item name="android:windowIsTranslucent">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
<style name="AccentButton" parent="Widget.AppCompat.Button.Colored">
<item name="android:textColor">#fff</item>
<item name="android:colorButtonNormal">@color/colorAccent</item>
</style>
<style name="AccentButton" parent="Widget.AppCompat.Button.Colored">
<item name="android:textColor">#fff</item>
<item name="android:colorButtonNormal">@color/colorAccent</item>
</style>
</resources>

View file

@ -1,38 +1,30 @@
apply from: './dependencies.gradle'
apply from: './versionsPlugin.gradle'
buildscript {
apply from: './dependencies.gradle'
apply from: './dependencies.gradle'
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:' + versions.gradlePlugin
classpath "com.diffplug.spotless:spotless-plugin-gradle:" + versions.spotlessPlugin
}
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:' + versions.gradlePlugin
classpath 'com.diffplug.spotless:spotless-plugin-gradle:' + versions.spotlessPlugin
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.kotlin
classpath 'com.github.ben-manes:gradle-versions-plugin:' + versions.versionPlugin
}
}
allprojects {
repositories {
google()
jcenter()
maven { url "https://jitpack.io" }
}
repositories {
google()
jcenter()
maven { url "https://dl.bintray.com/drummer-aidan/maven" }
maven { url "https://jitpack.io" }
}
buildscript {
repositories {
google()
}
}
apply plugin: "com.diffplug.gradle.spotless"
spotless {
java {
target "**/*.java"
trimTrailingWhitespace()
removeUnusedImports()
googleJavaFormat()
endWithNewline()
}
}
tasks.withType(Javadoc).all {
enabled = false
}
}

1
data/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

22
data/build.gradle Normal file
View file

@ -0,0 +1,22 @@
apply from: '../dependencies.gradle'
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion versions.compileSdk
defaultConfig {
minSdkVersion versions.minSdk
targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode
versionName versions.publishVersion
}
}
dependencies {
implementation project(':utilities')
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
}
apply from: '../spotless.gradle'

View file

@ -0,0 +1,2 @@
<manifest
package="com.afollestad.nocknock.data"/>

View file

@ -0,0 +1,77 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.data
import android.content.ContentValues
import android.database.Cursor
import com.afollestad.nocknock.data.ServerStatus.OK
import com.afollestad.nocknock.utilities.ext.timeString
import java.io.Serializable
import java.lang.System.currentTimeMillis
/** @author Aidan Follestad (afollestad)*/
data class ServerModel(
var id: Int = 0,
val name: String = "Unknown",
val url: String = "Unknown",
val status: ServerStatus = OK,
val checkInterval: Long = 0,
val lastCheck: Long = 0,
val reason: String? = null,
val validationMode: ValidationMode,
val validationContent: String? = null
) : Serializable {
companion object {
const val TABLE_NAME = "server_models"
const val COLUMN_ID = "_id"
const val COLUMN_NAME = "name"
const val COLUMN_URL = "url"
const val COLUMN_STATUS = "status"
const val COLUMN_CHECK_INTERVAL = "check_interval"
const val COLUMN_LAST_CHECK = "last_check"
const val COLUMN_REASON = "reason"
const val COLUMN_VALIDATION_MODE = "validation_mode"
const val COLUMN_VALIDATION_CONTENT = "validation_content"
const val DEFAULT_SORT_ORDER = "$COLUMN_NAME ASC"
fun pull(cursor: Cursor): ServerModel {
return ServerModel(
id = cursor.getInt(cursor.getColumnIndex(COLUMN_ID)),
name = cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
url = cursor.getString(cursor.getColumnIndex(COLUMN_URL)),
status = cursor.getInt(cursor.getColumnIndex(COLUMN_STATUS)).toServerStatus(),
checkInterval = cursor.getLong(cursor.getColumnIndex(COLUMN_CHECK_INTERVAL)),
lastCheck = cursor.getLong(cursor.getColumnIndex(COLUMN_LAST_CHECK)),
reason = cursor.getString(cursor.getColumnIndex(COLUMN_REASON)),
validationMode = cursor.getInt(
cursor.getColumnIndex(COLUMN_VALIDATION_MODE)
).toValidationMode(),
validationContent = cursor.getString(cursor.getColumnIndex(COLUMN_VALIDATION_CONTENT))
)
}
}
fun intervalText() = if (checkInterval <= 0) {
""
} else {
val now = currentTimeMillis()
val nextCheck = lastCheck + checkInterval
(nextCheck - now).timeString()
}
fun toContentValues() = ContentValues().apply {
put(COLUMN_NAME, name)
put(COLUMN_URL, url)
put(COLUMN_STATUS, status.value)
put(COLUMN_CHECK_INTERVAL, checkInterval)
put(COLUMN_LAST_CHECK, lastCheck)
put(COLUMN_REASON, reason)
put(COLUMN_VALIDATION_MODE, validationMode.value)
put(COLUMN_VALIDATION_CONTENT, validationContent)
}
}

View file

@ -0,0 +1,38 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.data
import com.afollestad.nocknock.data.ServerStatus.CHECKING
import com.afollestad.nocknock.data.ServerStatus.OK
import com.afollestad.nocknock.data.ServerStatus.WAITING
/** @author Aidan Follestad (afollestad) */
enum class ServerStatus(val value: Int) {
OK(1),
WAITING(2),
CHECKING(3),
ERROR(4);
companion object {
fun fromValue(value: Int) = when (value) {
OK.value -> OK
WAITING.value -> WAITING
CHECKING.value -> CHECKING
ERROR.value -> ERROR
else -> throw IllegalArgumentException("Unknown validationMode: $value")
}
}
}
fun ServerStatus.textRes() = when (this) {
OK -> R.string.everything_checks_out
WAITING -> R.string.waiting
CHECKING -> R.string.checking_status
else -> 0
}
fun Int.toServerStatus() = ServerStatus.fromValue(this)

View file

@ -0,0 +1,25 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.data
/** @author Aidan Follestad (afollestad) */
enum class ValidationMode(val value: Int) {
STATUS_CODE(1),
TERM_SEARCH(2),
JAVASCRIPT(3);
companion object {
fun fromValue(value: Int) = when (value) {
STATUS_CODE.value -> STATUS_CODE
TERM_SEARCH.value -> TERM_SEARCH
JAVASCRIPT.value -> JAVASCRIPT
else -> throw IllegalArgumentException("Unknown validationMode: $value")
}
}
}
fun Int.toValidationMode() = ValidationMode.fromValue(this)

View file

@ -0,0 +1,7 @@
<resources>
<string name="everything_checks_out">Everything checks out!</string>
<string name="checking_status">Checking status…</string>
<string name="waiting">Waiting…</string>
</resources>

View file

@ -1,13 +1,29 @@
ext.versions = [
minSdk : 21,
compileSdk : 27,
buildTools : '27.0.2',
publishVersion : '0.1.3.1',
publishVersionCode: 14,
gradlePlugin : '3.0.1',
spotlessPlugin : '3.8.0',
supportLib : '27.0.2',
materialDialogs : '0.9.6.0',
bridge : '5.1.2',
inquiry : '5.0.0'
]
minSdk : 21,
compileSdk : 28,
buildTools : '28.0.3',
publishVersion : '0.7.1',
publishVersionCode: 27,
gradlePlugin : '3.2.1',
spotlessPlugin : '3.16.0',
versionPlugin : '0.20.0',
okHttp : '3.12.0',
rhino : '1.7.10',
dagger : '2.19',
kotlin : '1.3.10',
coroutines : '1.0.1',
androidx : '1.0.0',
rxBinding : '3.0.0-alpha1',
materialDialogs : '2.0.0-rc3',
rxkPrefs : '1.2.0',
junit : '4.12',
mockito : '2.23.0',
mockitoKotlin : '2.0.0-RC1',
truth : '0.42'
]

1
engine/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

36
engine/build.gradle Normal file
View file

@ -0,0 +1,36 @@
apply from: '../dependencies.gradle'
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion versions.compileSdk
defaultConfig {
minSdkVersion versions.minSdk
targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode
versionName versions.publishVersion
}
}
dependencies {
implementation project(':utilities')
implementation project(':data')
implementation project(':notifications')
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:' + versions.coroutines
api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:' + versions.coroutines
api 'com.squareup.okhttp3:okhttp:' + versions.okHttp
implementation 'com.google.dagger:dagger:' + versions.dagger
kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
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
}
apply from: '../spotless.gradle'

View file

@ -0,0 +1,5 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.afollestad.nocknock.engine">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
</manifest>

View file

@ -0,0 +1,31 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.engine
import com.afollestad.nocknock.engine.db.RealServerModelStore
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager
import com.afollestad.nocknock.engine.statuscheck.RealCheckStatusManager
import dagger.Binds
import dagger.Module
import javax.inject.Singleton
/** @author Aidan Follestad (afollestad) */
@Module
abstract class EngineModule {
@Binds
@Singleton
abstract fun provideServerModelStore(
serverModelStore: RealServerModelStore
): ServerModelStore
@Binds
@Singleton
abstract fun provideCheckStatusManager(
checkStatusManager: RealCheckStatusManager
): CheckStatusManager
}

View file

@ -0,0 +1,54 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.engine.db
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import com.afollestad.nocknock.data.ServerModel
private const val SQL_CREATE_ENTRIES =
"CREATE TABLE ${ServerModel.TABLE_NAME} (" +
"${ServerModel.COLUMN_ID} INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," +
"${ServerModel.COLUMN_NAME} TEXT," +
"${ServerModel.COLUMN_URL} TEXT," +
"${ServerModel.COLUMN_STATUS} INTEGER," +
"${ServerModel.COLUMN_CHECK_INTERVAL} INTEGER," +
"${ServerModel.COLUMN_LAST_CHECK} INTEGER," +
"${ServerModel.COLUMN_REASON} TEXT," +
"${ServerModel.COLUMN_VALIDATION_MODE} INTEGER," +
"${ServerModel.COLUMN_VALIDATION_CONTENT} TEXT)"
private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${ServerModel.TABLE_NAME}"
/** @author Aidan Follestad (afollestad) */
class ServerModelDbHelper(context: Context) : SQLiteOpenHelper(
context, DATABASE_NAME, null, DATABASE_VERSION
) {
companion object {
const val DATABASE_VERSION = 1
const val DATABASE_NAME = "ServerModels.db"
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(SQL_CREATE_ENTRIES)
}
override fun onUpgrade(
db: SQLiteDatabase,
oldVersion: Int,
newVersion: Int
) {
db.execSQL(SQL_DELETE_ENTRIES)
onCreate(db)
}
override fun onDowngrade(
db: SQLiteDatabase,
oldVersion: Int,
newVersion: Int
) = onUpgrade(db, oldVersion, newVersion)
}

View file

@ -0,0 +1,126 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.engine.db
import android.app.Application
import android.database.Cursor
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerModel.Companion.COLUMN_ID
import com.afollestad.nocknock.data.ServerModel.Companion.DEFAULT_SORT_ORDER
import com.afollestad.nocknock.data.ServerModel.Companion.TABLE_NAME
import com.afollestad.nocknock.utilities.ext.diffFrom
import javax.inject.Inject
/** @author Aidan Follestad (afollestad) */
interface ServerModelStore {
suspend fun get(id: Int? = null): List<ServerModel>
suspend fun put(model: ServerModel): ServerModel
suspend fun update(model: ServerModel): Int
suspend fun delete(model: ServerModel): Int
suspend fun delete(id: Int): Int
suspend fun deleteAll(): Int
}
/** @author Aidan Follestad (afollestad) */
class RealServerModelStore @Inject constructor(
app: Application
) : ServerModelStore {
private val dbHelper = ServerModelDbHelper(app)
override suspend fun get(id: Int?): List<ServerModel> {
if (id == null) {
return getAll()
}
val reader = dbHelper.readableDatabase
val selection = "$COLUMN_ID = ?"
val selectionArgs = arrayOf("$id")
val cursor = reader.query(
TABLE_NAME,
null,
selection,
selectionArgs,
null,
null,
DEFAULT_SORT_ORDER,
"1"
)
cursor.use {
val results = readModels(it)
check(results.size == 1) { "Should only get one model per ID." }
return results
}
}
private fun getAll(): List<ServerModel> {
val reader = dbHelper.readableDatabase
val cursor = reader.query(
TABLE_NAME,
null,
null,
null,
null,
null,
DEFAULT_SORT_ORDER,
null
)
cursor.use { return readModels(it) }
}
override suspend fun put(model: ServerModel): ServerModel {
check(model.id == 0) { "Cannot put a model that already has an ID." }
val writer = dbHelper.writableDatabase
val newId = writer.insert(TABLE_NAME, null, model.toContentValues())
return model.copy(id = newId.toInt())
}
override suspend fun update(model: ServerModel): Int {
check(model.id != 0) { "Cannot update a model that does not have an ID." }
val oldModel = get(model.id).single()
val oldValues = oldModel.toContentValues()
val writer = dbHelper.writableDatabase
val newValues = model.toContentValues()
val valuesDiff = oldValues.diffFrom(newValues)
val selection = "$COLUMN_ID = ?"
val selectionArgs = arrayOf("${model.id}")
return writer.update(TABLE_NAME, valuesDiff, selection, selectionArgs)
}
override suspend fun delete(model: ServerModel) = delete(model.id)
override suspend fun delete(id: Int): Int {
check(id != 0) { "Cannot delete a model that doesn't have an ID." }
val selection = "$COLUMN_ID = ?"
val selectionArgs = arrayOf("$id")
return dbHelper.writableDatabase.delete(TABLE_NAME, selection, selectionArgs)
}
override suspend fun deleteAll(): Int {
return dbHelper.writableDatabase.delete(TABLE_NAME, null, null)
}
private fun readModels(cursor: Cursor): List<ServerModel> {
val results = mutableListOf<ServerModel>()
while (cursor.moveToNext()) {
results.add(ServerModel.pull(cursor))
}
return results
}
}

View file

@ -0,0 +1,167 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.engine.statuscheck
import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Intent
import android.util.Log
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus
import com.afollestad.nocknock.data.ServerStatus.CHECKING
import com.afollestad.nocknock.data.ServerStatus.ERROR
import com.afollestad.nocknock.data.ServerStatus.OK
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.engine.BuildConfig.APPLICATION_ID
import com.afollestad.nocknock.engine.db.ServerModelStore
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.utilities.BuildConfig
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.js.JavaScript
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.System.currentTimeMillis
import javax.inject.Inject
/** @author Aidan Follestad (afollestad)*/
class CheckStatusJob : JobService() {
companion object {
const val ACTION_STATUS_UPDATE = "$APPLICATION_ID.STATUS_UPDATE"
const val ACTION_JOB_RUNNING = "$APPLICATION_ID.STATUS_JOB_RUNNING"
const val KEY_UPDATE_MODEL = "site_model"
const val KEY_SITE_ID = "site.id"
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("CheckStatusJob", message)
}
}
}
@Inject lateinit var modelStore: ServerModelStore
@Inject lateinit var checkStatusManager: CheckStatusManager
@Inject lateinit var notificationManager: NockNotificationManager
override fun onStartJob(params: JobParameters): Boolean {
injector().injectInto(this)
val siteId = params.extras.getInt(KEY_SITE_ID)
GlobalScope.launch(Main) {
val sites = async(IO) { modelStore.get(id = siteId) }.await()
if (sites.isEmpty()) {
log("Unable to find any sites for ID $siteId, this job will not be rescheduled.")
return@launch jobFinished(params, false)
}
val site = sites.single()
log("Performing status checks on site ${site.id}...")
sendBroadcast(Intent(ACTION_JOB_RUNNING).apply { putExtra(KEY_SITE_ID, site.id) })
log("Checking ${site.name} (${site.url})...")
val result = async(IO) {
updateStatus(site, CHECKING)
val checkResult = checkStatusManager.performCheck(site)
val resultModel = checkResult.model
val resultResponse = checkResult.response
if (resultModel.status != OK) {
log("Got unsuccessful check status back: ${resultModel.reason}")
return@async updateStatus(site = resultModel)
} else {
when (site.validationMode) {
TERM_SEARCH -> {
val body = resultResponse?.body()?.string() ?: ""
log("Using TERM_SEARCH validation mode on body of length: ${body.length}")
return@async if (!body.contains(site.validationContent ?: "")) {
updateStatus(
resultModel.copy(
status = ERROR,
reason = "Term \"${site.validationContent}\" not found in response body."
)
)
} else {
resultModel
}
}
JAVASCRIPT -> {
val body = resultResponse?.body()?.string() ?: ""
log("Using JAVASCRIPT validation mode on body of length: ${body.length}")
val reason = JavaScript.eval(resultModel.validationContent ?: "", body)
return@async if (reason != null) {
updateStatus(resultModel.copy(reason = reason), status = ERROR)
} else {
resultModel
}
}
STATUS_CODE -> {
// We already know the status code is successful because we are in this else branch
updateStatus(
resultModel.copy(
status = OK,
reason = null
)
)
}
else -> {
throw IllegalArgumentException("Unknown validation mode: ${site.validationMode}")
}
}
}
}.await()
if (result.status == OK) {
notificationManager.cancelStatusNotification(result)
} else {
notificationManager.postStatusNotification(result)
}
checkStatusManager.scheduleCheck(result)
}
return true
}
override fun onStopJob(params: JobParameters): Boolean {
val siteId = params.extras.getInt(KEY_SITE_ID)
log("Check job for site $siteId is done")
return true
}
private suspend fun updateStatus(
site: ServerModel,
status: ServerStatus = site.status
): ServerModel {
log("Updating ${site.name} (${site.url}) status to $status...")
val lastCheckTime =
if (status == CHECKING) currentTimeMillis()
else site.lastCheck
val reason =
if (status == OK) null
else site.reason
val newSiteModel = site.copy(
status = status,
lastCheck = lastCheckTime,
reason = reason
)
modelStore.update(newSiteModel)
withContext(Main) {
sendBroadcast(Intent(ACTION_STATUS_UPDATE).apply { putExtra(KEY_UPDATE_MODEL, site) })
}
return newSiteModel
}
}

View file

@ -0,0 +1,143 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.engine.statuscheck
import android.app.Application
import android.app.job.JobInfo.NETWORK_TYPE_ANY
import android.app.job.JobScheduler
import android.app.job.JobScheduler.RESULT_SUCCESS
import android.os.PersistableBundle
import android.util.Log
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ServerStatus.ERROR
import com.afollestad.nocknock.data.ServerStatus.OK
import com.afollestad.nocknock.engine.R
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_SITE_ID
import com.afollestad.nocknock.utilities.BuildConfig
import com.afollestad.nocknock.utilities.providers.StringProvider
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.net.SocketTimeoutException
import javax.inject.Inject
/** @author Aidan Follestad (afollestad) */
data class CheckResult(
val model: ServerModel,
val response: Response? = null
)
/** @author Aidan Follestad (afollestad) */
interface CheckStatusManager {
fun scheduleCheck(
site: ServerModel,
rightNow: Boolean = false
)
fun cancelCheck(site: ServerModel)
suspend fun performCheck(site: ServerModel): CheckResult
}
class RealCheckStatusManager @Inject constructor(
private val app: Application,
private val jobScheduler: JobScheduler,
private val okHttpClient: OkHttpClient,
private val stringProvider: StringProvider
) : CheckStatusManager {
companion object {
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("CheckStatusManager", message)
}
}
}
override fun scheduleCheck(
site: ServerModel,
rightNow: Boolean
) {
check(site.id != 0) { "Cannot schedule checks for jobs with no ID." }
log("Requesting a check job for site to be scheduled: $site")
val extras = PersistableBundle().apply {
putInt(KEY_SITE_ID, site.id)
}
// Note that we don't use the periodic feature of JobScheduler because it requires a
// minimum of 15 minutes between each execution which may not be what's requested by the
// user of this app.
val jobInfo = jobInfo(app, site.id, CheckStatusJob::class.java) {
setRequiredNetworkType(NETWORK_TYPE_ANY)
if (rightNow) {
log(">> Job for site ${site.id} should be executed now")
setMinimumLatency(1)
} else {
log(">> Job for site ${site.id} should be in ${site.checkInterval}ms")
setMinimumLatency(site.checkInterval)
}
setExtras(extras)
setPersisted(true)
}
val dispatchResult = jobScheduler.schedule(jobInfo)
if (dispatchResult != RESULT_SUCCESS) {
log("Failed to schedule a check job for site: ${site.id}")
} else {
log("Check job successfully scheduled for site: ${site.id}")
}
}
override fun cancelCheck(site: ServerModel) {
check(site.id != 0) { "Cannot cancel scheduled checks for jobs with no ID." }
log("Cancelling scheduled checks for site: ${site.id}")
jobScheduler.cancel(site.id)
}
override suspend fun performCheck(site: ServerModel): CheckResult {
check(site.id != 0) { "Cannot schedule checks for jobs with no ID." }
log("performCheck(${site.id}) - GET ${site.url}")
val request = Request.Builder()
.url(site.url)
.get()
.build()
return try {
val response = okHttpClient.newCall(request)
.execute()
if (response.isSuccessful || response.code() == 401) {
log("performCheck(${site.id}) = Successful")
CheckResult(
model = site.copy(status = OK, reason = null),
response = response
)
} else {
log("performCheck(${site.id}) = Failure, HTTP code ${response.code()}")
CheckResult(
model = site.copy(
status = ERROR,
reason = "Response ${response.code()} - ${response.body()?.string() ?: "Unknown"}"
),
response = response
)
}
} catch (timeoutEx: SocketTimeoutException) {
log("performCheck(${site.id}) = Socket Timeout")
CheckResult(
model = site.copy(
status = ERROR,
reason = stringProvider.get(R.string.timeout)
)
)
} catch (ex: Exception) {
log("performCheck(${site.id}) = Error: ${ex.message}")
CheckResult(model = site.copy(status = ERROR, reason = ex.message))
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.engine.statuscheck
import android.app.job.JobInfo
import android.app.job.JobService
import android.content.ComponentName
import android.content.Context
typealias JobInfoBuilder = JobInfo.Builder
fun jobInfo(
context: Context,
id: Int,
target: Class<out JobService>,
exec: JobInfoBuilder.() -> JobInfoBuilder
): JobInfo {
val component = ComponentName(context, target)
val builder = JobInfo.Builder(id, component)
exec(builder)
return builder.build()
}

View file

@ -0,0 +1,6 @@
<resources>
<string name="check_service_name">Nock Nock Status Service</string>
<string name="timeout">Request timed out! Your server is probably down.</string>
</resources>

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip

1
notifications/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,35 @@
apply from: '../dependencies.gradle'
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion versions.compileSdk
defaultConfig {
minSdkVersion versions.minSdk
targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode
versionName versions.publishVersion
}
}
dependencies {
implementation project(':data')
implementation project(':utilities')
api 'androidx.appcompat:appcompat:' + versions.androidx
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:' + versions.coroutines
api 'org.jetbrains.kotlinx:kotlinx-coroutines-android:' + versions.coroutines
implementation 'com.google.dagger:dagger:' + versions.dagger
kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
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
}
apply from: '../spotless.gradle'

View file

@ -0,0 +1,2 @@
<manifest
package="com.afollestad.nocknock.notifications"/>

View file

@ -0,0 +1,38 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.notifications
import android.app.NotificationChannel
import android.content.Context
import android.os.Build.VERSION_CODES
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT
/** @author Aidan Follestad (afollestad) */
enum class Channel(
val id: String,
val title: Int,
val description: Int,
val importance: Int
) {
Statuses(
id = "statuses",
title = R.string.channel_server_status_title,
description = R.string.channel_server_status_description,
importance = IMPORTANCE_DEFAULT
)
}
/** @author Aidan Follestad (afollestad) */
@RequiresApi(VERSION_CODES.O)
fun Channel.toNotificationChannel(context: Context): NotificationChannel {
val titleText = context.getString(this.title)
val descriptionText = context.getString(this.description)
return NotificationChannel(this.id, titleText, this.importance)
.apply {
description = descriptionText
}
}

View file

@ -0,0 +1,111 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.notifications
import android.app.Application
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_CANCEL_CURRENT
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import androidx.core.app.NotificationCompat.DEFAULT_VIBRATE
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.notifications.Channel.Statuses
import com.afollestad.nocknock.utilities.providers.BitmapProvider
import com.afollestad.nocknock.utilities.providers.StringProvider
import com.afollestad.nocknock.utilities.qualifiers.AppIconRes
import com.afollestad.nocknock.utilities.util.hasOreo
import javax.inject.Inject
const val STATUS_NOTIFICATION_ID = 3456
/** @author Aidan Follestad (@afollestad) */
interface NockNotificationManager {
fun setIsAppOpen(open: Boolean)
fun createChannels()
fun postStatusNotification(model: ServerModel)
fun cancelStatusNotification(model: ServerModel)
fun cancelStatusNotifications()
}
/** @author Aidan Follestad (afollestad) */
class RealNockNotificationManager @Inject constructor(
private val app: Application,
@AppIconRes private val appIconRes: Int,
private val stockManager: NotificationManager,
private val bitmapProvider: BitmapProvider,
private val stringProvider: StringProvider
) : NockNotificationManager {
companion object {
private const val BASE_REQUEST_CODE = 44
const val KEY_MODEL = "model"
}
private var isAppOpen = false
override fun setIsAppOpen(open: Boolean) {
this.isAppOpen = open
}
override fun createChannels() {
Channel.values()
.forEach(this::createChannel)
}
override fun postStatusNotification(model: ServerModel) {
if (isAppOpen) {
// Don't show notifications while the app is open
return
}
val viewSiteActivityCls =
Class.forName("com.afollestad.nocknock.ui.ViewSiteActivity")
val openIntent = Intent(app, viewSiteActivityCls).apply {
putExtra(KEY_MODEL, model)
addFlags(FLAG_ACTIVITY_NEW_TASK)
}
val openPendingIntent = PendingIntent.getBroadcast(
app,
BASE_REQUEST_CODE + model.id,
openIntent,
FLAG_CANCEL_CURRENT
)
val newNotification = notification(app, Statuses) {
setContentTitle(model.name)
setContentText(stringProvider.get(R.string.something_wrong))
setContentIntent(openPendingIntent)
setSmallIcon(R.drawable.ic_notification)
setLargeIcon(bitmapProvider.get(appIconRes))
setAutoCancel(true)
setDefaults(DEFAULT_VIBRATE)
}
stockManager.notify(model.url, STATUS_NOTIFICATION_ID, newNotification)
}
override fun cancelStatusNotification(model: ServerModel) {
stockManager.cancel(BASE_REQUEST_CODE + model.id)
}
override fun cancelStatusNotifications() {
stockManager.cancelAll()
}
private fun createChannel(channel: Channel) {
if (!hasOreo()) {
return
}
val notificationChannel = channel.toNotificationChannel(app)
stockManager.createNotificationChannel(notificationChannel)
}
}

View file

@ -0,0 +1,24 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.notifications
import android.app.Notification
import android.content.Context
import androidx.core.app.NotificationCompat
typealias NotificationBuilder = NotificationCompat.Builder
typealias NotificationConstructor = NotificationBuilder.() -> Unit
/** @author Aidan Follestad (afollestad) */
fun notification(
context: Context,
channel: Channel,
builder: NotificationConstructor
): Notification {
val newNotification = NotificationCompat.Builder(context, channel.id)
builder(newNotification)
return newNotification.build()
}

View file

@ -0,0 +1,21 @@
/*
* Licensed under Apache-2.0
*
* Designed and developed by Aidan Follestad (@afollestad)
*/
package com.afollestad.nocknock.notifications
import dagger.Binds
import dagger.Module
import javax.inject.Singleton
/** @author Aidan Follestad (afollestad) */
@Module
abstract class NotificationsModule {
@Binds
@Singleton
abstract fun provideNockNotificationManager(
notificationManager: RealNockNotificationManager
): NockNotificationManager
}

View file

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 998 B

View file

Before

Width:  |  Height:  |  Size: 661 B

After

Width:  |  Height:  |  Size: 661 B

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

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