diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md deleted file mode 100644 index 2d7d09f..0000000 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug report -about: Something is crashing or not working as intended - ---- - -*Please consider making a Pull Request if you are capable of doing so.* - -**App Version:** - -x.x.x - -**Affected Device(s):** - -Google Pixel 3 XL with Android 9.0 - -**Describe the Bug:** - -A clear description of what is the bug is. - -**To Reproduce:** -1. -2. -3. - -**Expected Behavior:** - -A clear description of what you expected to happen. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md deleted file mode 100644 index 77310ae..0000000 --- a/.github/ISSUE_TEMPLATE/Feature_request.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project - ---- - -*Please consider making a Pull Request if you are capable of doing so.* - -**Description what you'd like to happen:** - -A clear description if the feature or behavior you'd like implemented. - -**Describe alternatives you've considered:** - -A clear description of any alternative solutions you've considered. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 6307e10..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,8 +0,0 @@ -### Guidelines - -1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`. -2. A PR should be focused and contained. If you are changing multiple unrelated things, they should be in separate PRs. -3. A PR should fix a bug or solve a problem - something that only you would use is not necessarily something that should be published. -4. Give your PR a detailed title and description - look over your code one last time before actually creating the PR. Give it a self-review. - -**If you do not follow the guidelines, your PR will be rejected.** diff --git a/.gitignore b/.gitignore index 454e51a..161128f 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,4 @@ gradle-app.setting .gradletasknamecache # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties - -app/google-services.json \ No newline at end of file +# gradle/wrapper/gradle-wrapper.properties \ No newline at end of file diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..f519041 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +nock-nock \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..96cc43e --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000..e7bedf3 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..97626ba --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 50f0406..fbb6828 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,49 +1,46 @@ - - - - + + - + + + + + + + + + + + - + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 23f5d23..56aca0f 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -3,12 +3,7 @@ - - - - - - + \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..70085e5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: android +jdk: oraclejdk8 +android: + components: + - tools + - platform-tools + - build-tools-24.0.1 + - android-24 + - extra-android-support + - extra-android-m2repository + - extra-google-m2repository + + # Additional components + #- extra-google-google_play_services + #- addon-google_apis-google-19 + + # Specify at least one system image, if you need to run emulator(s) during your tests + #- sys-img-armeabi-v7a-android-19 + #- sys-img-x86-android-17 + + licenses: + - '.+' \ No newline at end of file diff --git a/NockNock-0.1.3.0.apk b/NockNock-0.1.3.0.apk new file mode 100644 index 0000000..c4d3367 Binary files /dev/null and b/NockNock-0.1.3.0.apk differ diff --git a/README.md b/README.md index 84ee16c..a15f38e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ ## Nock Nock +[![Build Status](https://travis-ci.org/afollestad/nock-nock.svg)](https://travis-ci.org/afollestad/nock-nock) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html) -![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png) +![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcasemain.png) Nock Nock is a simple app which allows you to monitor your websites for maximum uptime. @@ -10,4 +11,4 @@ The app will automatically knock on the door of your websites (or web servers) o to make sure they are up and responding successfully. If something is wrong, you get a notification telling you so.
-Get it on Google Play +Get it on Google Play \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index d6b315e..23e53a1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,79 +1,43 @@ -apply from: '../dependencies.gradle' apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply plugin: 'kotlin-android-extensions' android { - compileSdkVersion versions.compileSdk - buildToolsVersion versions.buildTools + compileSdkVersion 24 + buildToolsVersion "24.0.1" - defaultConfig { - applicationId "com.afollestad.nocknock" - minSdkVersion versions.minSdk - targetSdkVersion versions.compileSdk - versionCode versions.publishVersionCode - versionName versions.publishVersion - } + defaultConfig { + applicationId "com.afollestad.nocknock" + minSdkVersion 21 + targetSdkVersion 24 + versionCode 13 + versionName "0.1.3.0" - compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 - } + lintOptions { + abortOnError false + } + jackOptions { + enabled true + } + } - packagingOptions { - exclude 'META-INF/atomicfu.kotlin_module' - } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } } dependencies { - implementation project(':common') - implementation project(':engine') - implementation project(':data') - implementation project(':notifications') - implementation project(':viewcomponents') - - // Google/AppCompat - implementation 'androidx.appcompat:appcompat:' + versions.androidxCore - implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView - implementation 'com.google.android.material:material:' + versions.googleMaterial - implementation 'androidx.browser:browser:' + versions.androidxBrowser - implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore - - // Lifecycle - kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle - - // Kotlin - implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin - - // JOIN - implementation 'org.koin:koin-android:' + versions.koin - implementation 'org.koin:koin-androidx-scope:' + versions.koin - implementation 'org.koin:koin-androidx-viewmodel:' + versions.koin - - // afollestad - implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs - - // Debugging - implementation 'com.jakewharton.timber:timber:' + versions.timber - implementation("com.crashlytics.sdk.android:crashlytics:${versions.fabric}") { - transitive = true - } - - // Testing - testImplementation 'junit:junit:' + versions.junit - testImplementation 'org.mockito:mockito-core:' + versions.mockito - testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin - testImplementation 'com.google.truth:truth:' + versions.truth - testImplementation 'androidx.arch.core:core-testing:' + versions.archTesting - - // UI testing - androidTestImplementation 'androidx.test:runner:' + versions.androidxTestRunner - androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner -} - -apply from: '../spotless.gradle' -apply from: '../mock/mock.gradle' - -apply plugin: "io.fabric" -apply plugin: 'com.google.gms.google-services' \ No newline at end of file + compile 'com.android.support:appcompat-v7:24.2.0' + compile 'com.android.support:design:24.2.0' + compile 'com.afollestad.material-dialogs:core:0.9.0.1' + compile 'com.afollestad.material-dialogs:commons:0.9.0.1' + compile 'com.afollestad:bridge:3.2.5' + compile 'com.afollestad:inquiry:3.2.1' + compile files('libs/rhino-1.7.7.1.jar') +} \ No newline at end of file diff --git a/app/libs/rhino-1.7.7.1.jar b/app/libs/rhino-1.7.7.1.jar new file mode 100644 index 0000000..a8b9417 Binary files /dev/null and b/app/libs/rhino-1.7.7.1.jar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..666743b --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in C:\Users\drumm\AppData\Local\Android\sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 332578e..d6a9d32 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,57 +3,60 @@ xmlns:tools="http://schemas.android.com/tools" package="com.afollestad.nocknock"> - - - + + + - + - - - + + + - - - + + + - + - + - + - - - - - + + + + + - + + + + + + - + - + \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/AppExt.kt b/app/src/main/java/com/afollestad/nocknock/AppExt.kt deleted file mode 100644 index 828e01f..0000000 --- a/app/src/main/java/com/afollestad/nocknock/AppExt.kt +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock - -import android.app.Activity -import android.app.Application -import android.app.Application.ActivityLifecycleCallbacks -import android.content.ActivityNotFoundException -import android.content.Intent -import android.os.Bundle -import androidx.browser.customtabs.CustomTabsIntent -import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY -import androidx.core.text.HtmlCompat.fromHtml -import com.afollestad.materialdialogs.utils.MDUtil.resolveColor -import com.afollestad.nocknock.utilities.ext.toUri -import com.afollestad.nocknock.utilities.ui.toast - -typealias ActivityLifeChange = (activity: Activity, resumed: Boolean) -> Unit - -/** @author Aidan Follestad (@afollestad) */ -fun Application.onActivityLifeChange(cb: ActivityLifeChange) { - registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { - override fun onActivitySaveInstanceState( - activity: Activity?, - outState: Bundle? - ) = Unit - - override fun onActivityPaused(activity: Activity) = cb(activity, false) - - override fun onActivityResumed(activity: Activity) = cb(activity, true) - - override fun onActivityStarted(activity: Activity) = Unit - - override fun onActivityDestroyed(activity: Activity) = Unit - - override fun onActivityStopped(activity: Activity) = Unit - - override fun onActivityCreated( - activity: Activity?, - savedInstanceState: Bundle? - ) = Unit - }) -} - -fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY) - -fun Activity.viewUrl(url: String) { - val customTabsIntent = CustomTabsIntent.Builder() - .apply { - setToolbarColor(resolveColor(this@viewUrl, attr = R.attr.colorPrimary)) - } - .build() - try { - customTabsIntent.launchUrl(this, url.toUri()) - } catch (_: ActivityNotFoundException) { - toast(R.string.install_web_browser) - } -} - -fun Activity.viewUrlWithApp( - url: String, - pkg: String -) { - val intent = Intent(Intent.ACTION_VIEW).apply { - data = url.toUri() - } - val resInfo = packageManager.queryIntentActivities(intent, 0) - for (info in resInfo) { - if (info.activityInfo.packageName.toLowerCase().contains(pkg) || - info.activityInfo.name.toLowerCase().contains(pkg) - ) { - startActivity(intent.apply { - setPackage(info.activityInfo.packageName) - }) - return - } - } - viewUrl(url) -} diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt deleted file mode 100644 index 3c29301..0000000 --- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:Suppress("unused") - -package com.afollestad.nocknock - -import android.app.Application -import com.afollestad.nocknock.BuildConfig.DEBUG -import com.afollestad.nocknock.engine.engineModule -import com.afollestad.nocknock.koin.mainModule -import com.afollestad.nocknock.koin.prefModule -import com.afollestad.nocknock.koin.viewModelModule -import com.afollestad.nocknock.logging.FabricTree -import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.notifications.notificationsModule -import com.afollestad.nocknock.utilities.commonModule -import com.crashlytics.android.Crashlytics -import io.fabric.sdk.android.Fabric -import org.koin.android.ext.android.inject -import org.koin.android.ext.android.startKoin -import timber.log.Timber -import timber.log.Timber.DebugTree -import timber.log.Timber.d as log - -/** @author Aidan Follestad (@afollestad) */ -class NockNockApp : Application() { - - private var resumedActivities: Int = 0 - - override fun onCreate() { - super.onCreate() - - if (DEBUG) { - Timber.plant(DebugTree()) - } - - Timber.plant(FabricTree()) - Fabric.with(this, Crashlytics()) - - val modules = listOf( - prefModule, - mainModule, - engineModule, - commonModule, - notificationsModule, - viewModelModule - ) - startKoin( - androidContext = this, - modules = modules - ) - - val nockNotificationManager by inject() - onActivityLifeChange { activity, resumed -> - if (resumed) { - resumedActivities++ - log("Activity resumed: $activity, resumedActivities = $resumedActivities") - } else { - resumedActivities-- - log("Activity paused: $activity, resumedActivities = $resumedActivities") - } - check(resumedActivities >= 0) { "resumedActivities can't go below 0." } - nockNotificationManager.setIsAppOpen(resumedActivities > 0) - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.java b/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.java new file mode 100644 index 0000000..b4da56b --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.java @@ -0,0 +1,171 @@ +package com.afollestad.nocknock.adapter; + +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.afollestad.nocknock.R; +import com.afollestad.nocknock.api.ServerModel; +import com.afollestad.nocknock.api.ServerStatus; +import com.afollestad.nocknock.util.TimeUtil; +import com.afollestad.nocknock.views.StatusImageView; + +import java.util.ArrayList; +import java.util.Collections; + +/** + * @author Aidan Follestad (afollestad) + */ +public class ServerAdapter extends RecyclerView.Adapter { + + private final Object LOCK = new Object(); + private ArrayList mServers; + private ClickListener mListener; + + public interface ClickListener { + void onSiteSelected(int index, ServerModel model, boolean longClick); + } + + public void performClick(int index, boolean longClick) { + if (mListener != null) { + mListener.onSiteSelected(index, mServers.get(index), longClick); + } + } + + public ServerAdapter(ClickListener listener) { + mListener = listener; + mServers = new ArrayList<>(2); + } + + public void add(ServerModel model) { + mServers.add(model); + notifyItemInserted(mServers.size() - 1); + } + + public void update(int index, ServerModel model) { + mServers.set(index, model); + notifyItemChanged(index); + } + + public void update(ServerModel model) { + synchronized (LOCK) { + for (int i = 0; i < mServers.size(); i++) { + if (mServers.get(i).id == model.id) { + update(i, model); + break; + } + } + } + } + + public void remove(int index) { + mServers.remove(index); + notifyItemRemoved(index); + } + + public void remove(ServerModel model) { + synchronized (LOCK) { + for (int i = 0; i < mServers.size(); i++) { + if (mServers.get(i).id == model.id) { + remove(i); + break; + } + } + } + } + + public void set(ServerModel[] models) { + if (models == null || models.length == 0) { + mServers.clear(); + return; + } + mServers = new ArrayList<>(models.length); + Collections.addAll(mServers, models); + notifyDataSetChanged(); + } + + public void clear() { + mServers.clear(); + notifyDataSetChanged(); + } + + @Override + public ServerAdapter.ServerVH onCreateViewHolder(ViewGroup parent, int viewType) { + final View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_server, parent, false); + return new ServerVH(v, this); + } + + @Override + public void onBindViewHolder(ServerAdapter.ServerVH holder, int position) { + final ServerModel model = mServers.get(position); + + holder.textName.setText(model.name); + holder.textUrl.setText(model.url); + holder.iconStatus.setStatus(model.status); + + switch (model.status) { + case ServerStatus.OK: + holder.textStatus.setText(R.string.everything_checks_out); + break; + case ServerStatus.WAITING: + holder.textStatus.setText(R.string.waiting); + break; + case ServerStatus.CHECKING: + holder.textStatus.setText(R.string.checking_status); + break; + case ServerStatus.ERROR: + holder.textStatus.setText(model.reason); + break; + } + + if (model.checkInterval <= 0) { + holder.textInterval.setText(""); + } else { + final long now = System.currentTimeMillis(); + final long nextCheck = model.lastCheck + model.checkInterval; + final long difference = nextCheck - now; + holder.textInterval.setText(TimeUtil.str(difference)); + } + } + + @Override + public int getItemCount() { + return mServers.size(); + } + + public static class ServerVH extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { + + final StatusImageView iconStatus; + final TextView textName; + final TextView textInterval; + final TextView textUrl; + final TextView textStatus; + final ServerAdapter adapter; + + public ServerVH(View itemView, ServerAdapter adapter) { + super(itemView); + iconStatus = (StatusImageView) itemView.findViewById(R.id.iconStatus); + textName = (TextView) itemView.findViewById(R.id.textName); + textInterval = (TextView) itemView.findViewById(R.id.textInterval); + textUrl = (TextView) itemView.findViewById(R.id.textUrl); + textStatus = (TextView) itemView.findViewById(R.id.textStatus); + this.adapter = adapter; + + itemView.setOnClickListener(this); + itemView.setOnLongClickListener(this); + } + + @Override + public void onClick(View view) { + adapter.performClick(getAdapterPosition(), false); + } + + @Override + public boolean onLongClick(View view) { + adapter.performClick(getAdapterPosition(), true); + return false; + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt deleted file mode 100644 index e1b034c..0000000 --- a/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.adapter - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil.calculateDiff -import androidx.recyclerview.widget.RecyclerView -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.Status.WAITING -import com.afollestad.nocknock.data.model.isPending -import com.afollestad.nocknock.data.model.textRes -import com.afollestad.nocknock.utilities.ui.onDebouncedClick -import kotlinx.android.synthetic.main.list_item_server.view.iconStatus -import kotlinx.android.synthetic.main.list_item_server.view.textInterval -import kotlinx.android.synthetic.main.list_item_server.view.textName -import kotlinx.android.synthetic.main.list_item_server.view.textStatus -import kotlinx.android.synthetic.main.list_item_server.view.textUrl - -typealias Listener = (model: Site, longClick: Boolean) -> Unit - -/** @author Aidan Follestad (@afollestad) */ -class SiteViewHolder constructor( - itemView: View, - private val adapter: SiteAdapter -) : RecyclerView.ViewHolder(itemView), View.OnLongClickListener { - - init { - itemView.onDebouncedClick { - adapter.performClick(adapterPosition, false) - } - itemView.setOnLongClickListener(this) - } - - fun bind(model: Site) { - requireNotNull(model.settings) { "Settings must be populated." } - - itemView.textName.text = model.name - itemView.textUrl.text = model.url - - val lastResult = model.lastResult - if (lastResult != null) { - itemView.iconStatus.setStatus(lastResult.status) - val statusText = lastResult.status.textRes() - if (statusText == 0) { - itemView.textStatus.text = lastResult.reason - } else { - itemView.textStatus.setText(statusText) - } - } else { - itemView.iconStatus.setStatus(WAITING) - itemView.textStatus.setText(R.string.none) - } - - val res = itemView.resources - when { - model.settings?.disabled == true -> { - itemView.textInterval.setText(R.string.checks_disabled) - } - model.lastResult?.status.isPending() -> { - itemView.textInterval.text = res.getString( - R.string.next_check_x, - res.getString(R.string.now) - ) - } - else -> { - itemView.textInterval.text = res.getString( - R.string.next_check_x, - model.intervalText() - ) - } - } - } - - override fun onLongClick(view: View): Boolean { - adapter.performClick(adapterPosition, true) - return false - } -} - -/** @author Aidan Follestad (@afollestad) */ -class SiteAdapter(private val listener: Listener) : RecyclerView.Adapter() { - - private var models = mutableListOf() - - internal fun performClick( - index: Int, - longClick: Boolean - ) = listener.invoke(models[index], longClick) - - fun set(newModels: List) { - val formerModels = this.models - this.models = newModels.toMutableList() - val diffResult = calculateDiff(SiteDiffCallback(formerModels, this.models)) - diffResult.dispatchUpdatesTo(this) - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): SiteViewHolder { - val v = LayoutInflater.from(parent.context) - .inflate(R.layout.list_item_server, parent, false) - return SiteViewHolder(v, this) - } - - override fun onBindViewHolder( - holder: SiteViewHolder, - position: Int - ) { - val model = models[position] - holder.bind(model) - } - - override fun getItemCount() = models.size -} diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt deleted file mode 100644 index de8b7ca..0000000 --- a/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.adapter - -import androidx.recyclerview.widget.DiffUtil -import com.afollestad.nocknock.data.model.Site - -/** @author Aidan Follestad (@afollestad) */ -class SiteDiffCallback( - private val oldItems: List, - private val newItems: List -) : DiffUtil.Callback() { - - override fun getOldListSize() = oldItems.size - - override fun getNewListSize() = newItems.size - - override fun areItemsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ) = oldItems[oldItemPosition].id == newItems[newItemPosition].id - - override fun areContentsTheSame( - oldItemPosition: Int, - newItemPosition: Int - ) = oldItems[oldItemPosition] == newItems[newItemPosition] -} diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt deleted file mode 100644 index 7bf22e5..0000000 --- a/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.adapter - -import android.graphics.Color.WHITE -import android.view.LayoutInflater -import android.view.View -import android.view.View.OnClickListener -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ViewHolder -import com.afollestad.nocknock.R -import com.afollestad.nocknock.adapter.TagAdapter.TagViewHolder -import kotlinx.android.synthetic.main.list_item_tag.view.chip - -typealias TagsListener = (tags: List) -> Unit - -/** @author Aidan Follestad (@afollestad) */ -class TagAdapter( - private val listener: TagsListener -) : RecyclerView.Adapter() { - - private val tags = mutableListOf() - private val checked = mutableListOf() - - fun set(tags: List) { - this.tags.run { - clear() - addAll(tags) - } - notifyDataSetChanged() - } - - fun toggleChecked(index: Int) { - if (checked.contains(index)) { - checked.remove(index) - } else { - checked.add(index) - } - notifyItemChanged(index) - listener.invoke(getCheckedTags()) - } - - private fun getCheckedTags(): List { - return mutableListOf().apply { - checked.forEach { index -> add(tags[index]) } - } - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): TagViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.list_item_tag, parent, false) - return TagViewHolder(view, this) - } - - override fun getItemCount() = tags.size - - override fun onBindViewHolder( - holder: TagViewHolder, - position: Int - ) { - holder.bind(tags[position], checked.contains(position)) - } - - /** @author Aidan Follestad (@afollestad) */ - class TagViewHolder( - itemView: View, - private val adapter: TagAdapter - ) : ViewHolder(itemView), OnClickListener { - - override fun onClick(v: View) = adapter.toggleChecked(adapterPosition) - - init { - itemView.setOnClickListener(this) - } - - fun bind( - name: String, - checked: Boolean - ) = itemView.chip.run { - text = name - setTextColor( - if (checked) { - WHITE - } else { - ContextCompat.getColor(itemView.context, R.color.unchecked_chip_text) - } - ) - setBackgroundResource( - if (checked) { - R.drawable.checked_chip_selector - } else { - R.drawable.unchecked_chip_selector - } - ) - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/api/ServerModel.java b/app/src/main/java/com/afollestad/nocknock/api/ServerModel.java new file mode 100644 index 0000000..1e3b6df --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/api/ServerModel.java @@ -0,0 +1,36 @@ +package com.afollestad.nocknock.api; + +import com.afollestad.inquiry.annotations.Column; + +import java.io.Serializable; + +/** + * @author Aidan Follestad (afollestad) + */ +public class ServerModel implements Serializable { + + public ServerModel() { + } + + @Column(name = "_id", primaryKey = true, notNull = true, autoIncrement = true) + public long id; + @Column + public String name; + @Column + public String url; + @Column + @ServerStatus.Enum + public int status; + @Column + public long checkInterval; + @Column + public long lastCheck; + @Column + public String reason; + + @Column + @ValidationMode.Enum + public int validationMode; + @Column + public String validationContent; +} \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/api/ServerStatus.java b/app/src/main/java/com/afollestad/nocknock/api/ServerStatus.java new file mode 100644 index 0000000..f7f98fe --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/api/ServerStatus.java @@ -0,0 +1,21 @@ +package com.afollestad.nocknock.api; + +import android.support.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * @author Aidan Follestad (afollestad) + */ +public final class ServerStatus { + + public final static int OK = 1; + public final static int WAITING = 2; + public final static int CHECKING = 3; + public final static int ERROR = 4; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({OK, WAITING, CHECKING, ERROR}) + public @interface Enum {} +} diff --git a/app/src/main/java/com/afollestad/nocknock/api/ValidationMode.java b/app/src/main/java/com/afollestad/nocknock/api/ValidationMode.java new file mode 100644 index 0000000..2606635 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/api/ValidationMode.java @@ -0,0 +1,21 @@ +package com.afollestad.nocknock.api; + +import android.support.annotation.IntDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * @author Aidan Follestad (afollestad) + */ +public final class ValidationMode { + + public final static int STATUS_CODE = 1; + public final static int TERM_SEARCH = 2; + public final static int JAVASCRIPT = 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATUS_CODE, TERM_SEARCH, JAVASCRIPT}) + public @interface Enum { + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt deleted file mode 100644 index c5567c5..0000000 --- a/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.broadcasts - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import androidx.lifecycle.Lifecycle.Event.ON_DESTROY -import androidx.lifecycle.Lifecycle.Event.ON_PAUSE -import androidx.lifecycle.Lifecycle.Event.ON_RESUME -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.OnLifecycleEvent -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL -import com.afollestad.nocknock.utilities.providers.IntentProvider - -typealias SiteCallback = (Site) -> Unit - -/** @author Aidan Follestad (@afollestad) */ -class StatusUpdateIntentReceiver( - private val context: Context, - private val intentProvider: IntentProvider, - private var callback: SiteCallback? -) : LifecycleObserver { - - internal val intentReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent - ) { - if (intent.action == ACTION_STATUS_UPDATE) { - val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site - ?: return - callback?.invoke(model) - } - } - } - - @OnLifecycleEvent(ON_RESUME) - fun onResume() { - val filter = intentProvider.createFilter(ACTION_STATUS_UPDATE) - context.registerReceiver(intentReceiver, filter) - } - - @OnLifecycleEvent(ON_PAUSE) - fun onPause() { - context.unregisterReceiver(intentReceiver) - } - - @OnLifecycleEvent(ON_DESTROY) - fun onDestroy() { - callback = null - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.java b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.java new file mode 100644 index 0000000..3eeed80 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.java @@ -0,0 +1,33 @@ +package com.afollestad.nocknock.dialogs; + +import android.app.Dialog; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; +import android.support.v7.app.AppCompatActivity; +import android.text.Html; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.afollestad.nocknock.R; + +/** + * @author Aidan Follestad (afollestad) + */ +public class AboutDialog extends DialogFragment { + + public static void show(AppCompatActivity context) { + AboutDialog dialog = new AboutDialog(); + dialog.show(context.getSupportFragmentManager(), "[ABOUT_DIALOG]"); + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return new MaterialDialog.Builder(getActivity()) + .title(R.string.about) + .positiveText(R.string.dismiss) + .content(Html.fromHtml(getString(R.string.about_body))) + .contentLineSpacing(1.6f) + .build(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt deleted file mode 100644 index f0c152f..0000000 --- a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.dialogs - -import android.app.Dialog -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.DialogFragment -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.nocknock.BuildConfig -import com.afollestad.nocknock.R - -/** @author Aidan Follestad (@afollestad) */ -class AboutDialog : DialogFragment() { - companion object { - private const val TAG = "[ABOUT_DIALOG]" - - fun show(context: AppCompatActivity) { - val dialog = AboutDialog() - dialog.show(context.supportFragmentManager, TAG) - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = activity ?: throw IllegalStateException("Oh no!") - return MaterialDialog(context) - .title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME)) - .positiveButton(R.string.dismiss) - .message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt deleted file mode 100644 index 07f6410..0000000 --- a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.koin - -import android.app.Application -import android.app.NotificationManager -import android.app.job.JobScheduler -import android.content.Context.JOB_SCHEDULER_SERVICE -import android.content.Context.NOTIFICATION_SERVICE -import androidx.room.Room.databaseBuilder -import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.Database1to2Migration -import com.afollestad.nocknock.data.Database2to3Migration -import com.afollestad.nocknock.data.Database3to4Migration -import com.afollestad.nocknock.data.Database4to5Migration -import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS -import com.afollestad.nocknock.ui.main.MainActivity -import com.afollestad.nocknock.utilities.ext.systemService -import okhttp3.OkHttpClient -import org.koin.dsl.module.module - -val mainActivityCls = MainActivity::class.java - -/** @author Aidan Follestad (@afollestad) */ -val mainModule = module { - - single(name = MAIN_ACTIVITY_CLASS) { mainActivityCls } - - single { - databaseBuilder(get(), AppDatabase::class.java, "NockNock.db") - .addMigrations( - Database1to2Migration(), - Database2to3Migration(), - Database3to4Migration(), - Database4to5Migration() - ) - .build() - } - - single { - OkHttpClient.Builder() - .addNetworkInterceptor { chain -> - val request = chain.request() - .newBuilder() - .addHeader("User-Agent", "com.afollestad.nocknock") - .build() - chain.proceed(request) - } - .build() - } - - single { - get().systemService(JOB_SCHEDULER_SERVICE) - } - - single { - get().systemService(NOTIFICATION_SERVICE) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt deleted file mode 100644 index 654b76d..0000000 --- a/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.koin - -import com.afollestad.rxkprefs.RxkPrefs -import com.afollestad.rxkprefs.rxkPrefs -import org.koin.dsl.module.module - -const val PREF_DARK_MODE = "dark_mode" - -/** @author Aidan Follestad (@afollestad) */ -val prefModule = module { - - single { rxkPrefs(get(), "settings") } - - factory(name = PREF_DARK_MODE) { - get().boolean(PREF_DARK_MODE, false) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt deleted file mode 100644 index ab8cd79..0000000 --- a/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.koin - -import com.afollestad.nocknock.ui.addsite.AddSiteViewModel -import com.afollestad.nocknock.ui.main.MainViewModel -import com.afollestad.nocknock.ui.viewsite.ViewSiteViewModel -import com.afollestad.nocknock.utilities.Qualifiers.IO_DISPATCHER -import com.afollestad.nocknock.utilities.Qualifiers.MAIN_DISPATCHER -import org.koin.androidx.viewmodel.ext.koin.viewModel -import org.koin.dsl.module.module - -/** @author Aidan Follestad (@afollestad) */ -val viewModelModule = module { - - viewModel { - MainViewModel( - get(), - get(), - get(), - get(name = MAIN_DISPATCHER), - get(name = IO_DISPATCHER) - ) - } - - viewModel { - AddSiteViewModel( - get(), - get(), - get(name = MAIN_DISPATCHER), - get(name = IO_DISPATCHER) - ) - } - - viewModel { - ViewSiteViewModel( - get(), - get(), - get(), - get(), - get(name = MAIN_DISPATCHER), - get(name = IO_DISPATCHER) - ) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt b/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt deleted file mode 100644 index 10b97fb..0000000 --- a/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.logging - -import com.crashlytics.android.Crashlytics -import timber.log.Timber - -/** @author Aidan Follestad (@afollestad) */ -class FabricTree : Timber.Tree() { - - override fun log( - priority: Int, - tag: String?, - message: String, - t: Throwable? - ) { - if (t != null) { - Crashlytics.setString("crash_tag", tag) - Crashlytics.logException(t) - } else { - Crashlytics.log(priority, tag, message) - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/receivers/BootReceiver.java b/app/src/main/java/com/afollestad/nocknock/receivers/BootReceiver.java new file mode 100644 index 0000000..4f5666f --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/receivers/BootReceiver.java @@ -0,0 +1,28 @@ +package com.afollestad.nocknock.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import com.afollestad.inquiry.Inquiry; +import com.afollestad.nocknock.api.ServerModel; +import com.afollestad.nocknock.ui.MainActivity; +import com.afollestad.nocknock.util.AlarmUtil; + +/** + * @author Aidan Follestad (afollestad) + */ +public class BootReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals("android.intent.action.BOOT_COMPLETED")) { + final Inquiry inq = Inquiry.newInstance(context, MainActivity.DB_NAME).build(false); + ServerModel[] models = inq + .selectFrom(MainActivity.SITES_TABLE_NAME, ServerModel.class) + .all(); + AlarmUtil.setSiteChecks(context, models); + inq.destroyInstance(); + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/receivers/ConnectivityReceiver.java b/app/src/main/java/com/afollestad/nocknock/receivers/ConnectivityReceiver.java new file mode 100644 index 0000000..7ceed62 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/receivers/ConnectivityReceiver.java @@ -0,0 +1,25 @@ +package com.afollestad.nocknock.receivers; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.afollestad.nocknock.services.CheckService; +import com.afollestad.nocknock.util.NetworkUtil; + +/** + * @author Aidan Follestad (afollestad) + */ +public class ConnectivityReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + final boolean hasInternet = NetworkUtil.hasInternet(context); + Log.v("ConnectivityReceiver", "Connectivity state changed... has internet? " + hasInternet); + if (hasInternet) { + context.startService(new Intent(context, CheckService.class) + .putExtra(CheckService.ONLY_WAITING, true)); + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/services/CheckService.java b/app/src/main/java/com/afollestad/nocknock/services/CheckService.java new file mode 100644 index 0000000..2b9ab94 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/services/CheckService.java @@ -0,0 +1,241 @@ +package com.afollestad.nocknock.services; + +import android.app.IntentService; +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.graphics.BitmapFactory; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.util.Log; +import android.widget.Toast; + +import com.afollestad.bridge.Bridge; +import com.afollestad.bridge.BridgeException; +import com.afollestad.bridge.Response; +import com.afollestad.inquiry.Inquiry; +import com.afollestad.inquiry.Query; +import com.afollestad.nocknock.BuildConfig; +import com.afollestad.nocknock.R; +import com.afollestad.nocknock.api.ServerModel; +import com.afollestad.nocknock.api.ServerStatus; +import com.afollestad.nocknock.api.ValidationMode; +import com.afollestad.nocknock.ui.MainActivity; +import com.afollestad.nocknock.ui.ViewSiteActivity; +import com.afollestad.nocknock.util.JsUtil; +import com.afollestad.nocknock.util.NetworkUtil; + +import java.util.Locale; + +/** + * @author Aidan Follestad (afollestad) + */ +@SuppressWarnings("CheckResult") +public class CheckService extends IntentService { + + public static String ACTION_CHECK_UPDATE = BuildConfig.APPLICATION_ID + ".CHECK_UPDATE"; + public static String ACTION_RUNNING = BuildConfig.APPLICATION_ID + ".CHECK_RUNNING"; + public static String MODEL_ID = "model_id"; + public static String ONLY_WAITING = "only_waiting"; + public static int NOTI_ID = 3456; + + public CheckService() { + super("NockNockCheckService"); + } + + private static void LOG(String msg, Object... format) { + if (format != null) + msg = String.format(Locale.getDefault(), msg, format); + Log.v("NockNockService", msg); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + protected void onHandleIntent(Intent intent) { + Inquiry.newInstance(this, MainActivity.DB_NAME).build(); + isRunning(true); + Bridge.config() + .defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)"); + + final Query query = Inquiry.get(this) + .selectFrom(MainActivity.SITES_TABLE_NAME, ServerModel.class); + if (intent != null && intent.hasExtra(MODEL_ID)) { + query.where("_id = ?", intent.getLongExtra(MODEL_ID, -1)); + } else if (intent != null && intent.getBooleanExtra(ONLY_WAITING, false)) { + query.where("status = ?", ServerStatus.WAITING); + } + final ServerModel[] sites = query.all(); + + if (sites == null || sites.length == 0) { + LOG("No sites added to check, service will terminate."); + isRunning(false); + stopSelf(); + return; + } + + LOG("Checking %d sites...", sites.length); + sendBroadcast(new Intent(ACTION_RUNNING)); + + for (ServerModel site : sites) { + LOG("Updating %s (%s) status to WAITING...", site.name, site.url); + site.status = ServerStatus.WAITING; + updateStatus(site); + } + + if (NetworkUtil.hasInternet(this)) { + for (ServerModel site : sites) { + LOG("Checking %s (%s)...", site.name, site.url); + site.status = ServerStatus.CHECKING; + site.lastCheck = System.currentTimeMillis(); + updateStatus(site); + + try { + final Response response = Bridge.get(site.url) + .throwIfNotSuccess() + .cancellable(false) + .request() + .response(); + + site.reason = null; + site.status = ServerStatus.OK; + + if (site.validationMode == ValidationMode.TERM_SEARCH) { + final String body = response.asString(); + if (body == null || !body.contains(site.validationContent)) { + site.status = ServerStatus.ERROR; + site.reason = "Term \"" + site.validationContent + "\" not found in response body."; + } + } else if (site.validationMode == ValidationMode.JAVASCRIPT) { + final String body = response.asString(); + site.reason = JsUtil.exec(site.validationContent, body); + if (site.reason != null && !site.toString().isEmpty()) + site.status = ServerStatus.ERROR; + } + + if (site.status == ServerStatus.ERROR) + showNotification(this, site); + } catch (BridgeException e) { + processError(e, site); + } + updateStatus(site); + } + } else { + LOG("No internet connection, waiting."); + } + + isRunning(false); + LOG("Service is finished!"); + } + + private void processError(BridgeException e, ServerModel site) { + site.status = ServerStatus.OK; + site.reason = null; + + switch (e.reason()) { + case BridgeException.REASON_REQUEST_CANCELLED: + // Shouldn't happen + break; + case BridgeException.REASON_REQUEST_FAILED: + case BridgeException.REASON_RESPONSE_UNPARSEABLE: + case BridgeException.REASON_RESPONSE_UNSUCCESSFUL: + case BridgeException.REASON_RESPONSE_IOERROR: + //noinspection ConstantConditions + if (e.response() != null && e.response().code() == 401) { + // Don't consider 401 unsuccessful here + site.reason = null; + } else { + site.status = ServerStatus.ERROR; + site.reason = e.getMessage(); + } + break; + case BridgeException.REASON_REQUEST_TIMEOUT: + site.status = ServerStatus.ERROR; + site.reason = getString(R.string.timeout); + break; + case BridgeException.REASON_RESPONSE_VALIDATOR_ERROR: + case BridgeException.REASON_RESPONSE_VALIDATOR_FALSE: + // Not used + break; + } + + if (site.status != ServerStatus.OK) { + LOG("%s error: %s", site.name, site.reason); + showNotification(this, site); + } + } + + private void updateStatus(ServerModel site) { + Inquiry.get(this) + .update(MainActivity.SITES_TABLE_NAME, ServerModel.class) + .where("_id = ?", site.id) + .values(site) + .run(); + sendBroadcast(new Intent(ACTION_CHECK_UPDATE) + .putExtra("model", site)); + } + + private void isRunning(boolean running) { + PreferenceManager.getDefaultSharedPreferences(this) + .edit().putBoolean("check_service_running", running).commit(); + } + + public static boolean isRunning(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean("check_service_running", false); + } + + public static void isAppOpen(Context context, boolean open) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putBoolean("is_app_open", open).commit(); + } + + public static boolean isAppOpen(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean("is_app_open", false); + } + + private static void showNotification(Context context, ServerModel site) { + if (isAppOpen(context)) { + // Don't show notifications while the app is open + return; + } + + final NotificationManagerCompat nm = NotificationManagerCompat.from(context); + final PendingIntent openIntent = PendingIntent.getActivity(context, 9669, + new Intent(context, ViewSiteActivity.class) + .putExtra("model", site) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_CANCEL_CURRENT); + final Notification noti = new NotificationCompat.Builder(context) + .setContentTitle(site.name) + .setContentText(context.getString(R.string.something_wrong)) + .setContentIntent(openIntent) + .setSmallIcon(R.drawable.ic_notification) + .setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher)) + .setPriority(Notification.PRIORITY_HIGH) + .setAutoCancel(true) + .setDefaults(Notification.DEFAULT_VIBRATE) + .build(); + nm.notify(site.url, NOTI_ID, noti); + } + + @Override + public void onDestroy() { + try { + Inquiry.destroy(this); + } catch (Throwable t2) { + t2.printStackTrace(); + } + super.onDestroy(); + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.java b/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.java new file mode 100644 index 0000000..cb54ece --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.java @@ -0,0 +1,255 @@ +package com.afollestad.nocknock.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.design.widget.TextInputLayout; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.util.Patterns; +import android.view.View; +import android.view.ViewAnimationUtils; +import android.view.ViewTreeObserver; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import com.afollestad.nocknock.R; +import com.afollestad.nocknock.api.ServerModel; +import com.afollestad.nocknock.api.ServerStatus; +import com.afollestad.nocknock.api.ValidationMode; + +/** + * @author Aidan Follestad (afollestad) + */ +public class AddSiteActivity extends AppCompatActivity implements View.OnClickListener { + + private View rootLayout; + private Toolbar toolbar; + + private TextInputLayout nameTiLayout; + private EditText inputName; + private TextInputLayout urlTiLayout; + private EditText inputUrl; + private EditText inputInterval; + private Spinner spinnerInterval; + private TextView textUrlWarning; + private Spinner responseValidationSpinner; + + private boolean isClosing; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_addsite); + + rootLayout = findViewById(R.id.rootView); + nameTiLayout = (TextInputLayout) findViewById(R.id.nameTiLayout); + inputName = (EditText) findViewById(R.id.inputName); + urlTiLayout = (TextInputLayout) findViewById(R.id.urlTiLayout); + inputUrl = (EditText) findViewById(R.id.inputUrl); + textUrlWarning = (TextView) findViewById(R.id.textUrlWarning); + inputInterval = (EditText) findViewById(R.id.checkIntervalInput); + spinnerInterval = (Spinner) findViewById(R.id.checkIntervalSpinner); + responseValidationSpinner = (Spinner) findViewById(R.id.responseValidationMode); + + toolbar = (Toolbar) findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(view -> closeActivityWithReveal()); + + if (savedInstanceState == null) { + rootLayout.setVisibility(View.INVISIBLE); + ViewTreeObserver viewTreeObserver = rootLayout.getViewTreeObserver(); + if (viewTreeObserver.isAlive()) { + viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + circularRevealActivity(); + rootLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this); + } + }); + } + } + + ArrayAdapter 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 validationOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner, + getResources().getStringArray(R.array.response_validation_options)); + validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown); + responseValidationSpinner.setAdapter(validationOptionsAdapter); + responseValidationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView adapterView, View view, int i, long l) { + final View searchTerm = findViewById(R.id.responseValidationSearchTerm); + final View javascript = findViewById(R.id.responseValidationScript); + final TextView modeDesc = (TextView) findViewById(R.id.validationModeDescription); + + searchTerm.setVisibility(i == 1 ? View.VISIBLE : View.GONE); + javascript.setVisibility(i == 2 ? View.VISIBLE : View.GONE); + + switch (i) { + case 0: + modeDesc.setText(R.string.validation_mode_status_desc); + break; + case 1: + modeDesc.setText(R.string.validation_mode_term_desc); + break; + case 2: + modeDesc.setText(R.string.validation_mode_javascript_desc); + break; + } + } + + @Override + public void onNothingSelected(AdapterView adapterView) { + } + }); + + findViewById(R.id.doneBtn).setOnClickListener(this); + } + + @Override + public void onBackPressed() { + closeActivityWithReveal(); + } + + private void closeActivityWithReveal() { + if (isClosing) return; + isClosing = true; + final int fabSize = getIntent().getIntExtra("fab_size", toolbar.getMeasuredHeight()); + final int cx = (int) getIntent().getFloatExtra("fab_x", rootLayout.getMeasuredWidth() / 2) + (fabSize / 2); + final int cy = (int) getIntent().getFloatExtra("fab_y", rootLayout.getMeasuredHeight() / 2) + toolbar.getMeasuredHeight() + (fabSize / 2); + float initialRadius = Math.max(cx, cy); + + final Animator circularReveal = ViewAnimationUtils.createCircularReveal(rootLayout, cx, cy, initialRadius, 0); + circularReveal.setDuration(300); + circularReveal.setInterpolator(new AccelerateInterpolator()); + circularReveal.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + rootLayout.setVisibility(View.INVISIBLE); + finish(); + overridePendingTransition(0, 0); + } + }); + + circularReveal.start(); + } + + private void circularRevealActivity() { + final int cx = rootLayout.getMeasuredWidth() / 2; + final int cy = rootLayout.getMeasuredHeight() / 2; + final float finalRadius = Math.max(cx, cy); + final Animator circularReveal = ViewAnimationUtils.createCircularReveal(rootLayout, cx, cy, 0, finalRadius); + + circularReveal.setDuration(300); + circularReveal.setInterpolator(new DecelerateInterpolator()); + + rootLayout.setVisibility(View.VISIBLE); + circularReveal.start(); + } + + // Done button + @Override + public void onClick(View view) { + isClosing = true; + + ServerModel model = new ServerModel(); + model.name = inputName.getText().toString().trim(); + model.url = inputUrl.getText().toString().trim(); + model.status = ServerStatus.WAITING; + + if (model.name.isEmpty()) { + nameTiLayout.setError(getString(R.string.please_enter_name)); + isClosing = false; + return; + } else { + nameTiLayout.setError(null); + } + + if (model.url.isEmpty()) { + urlTiLayout.setError(getString(R.string.please_enter_url)); + isClosing = false; + return; + } else { + urlTiLayout.setError(null); + if (!Patterns.WEB_URL.matcher(model.url).find()) { + urlTiLayout.setError(getString(R.string.please_enter_valid_url)); + isClosing = false; + return; + } else { + final Uri uri = Uri.parse(model.url); + if (uri.getScheme() == null) + model.url = "http://" + model.url; + } + } + + String intervalStr = inputInterval.getText().toString().trim(); + if (intervalStr.isEmpty()) intervalStr = "0"; + model.checkInterval = Integer.parseInt(intervalStr); + + switch (spinnerInterval.getSelectedItemPosition()) { + case 0: // minutes + model.checkInterval *= (60 * 1000); + break; + case 1: // hours + model.checkInterval *= (60 * 60 * 1000); + break; + case 2: // days + model.checkInterval *= (60 * 60 * 24 * 1000); + break; + default: // weeks + model.checkInterval *= (60 * 60 * 24 * 7 * 1000); + break; + } + + model.lastCheck = System.currentTimeMillis() - model.checkInterval; + + switch (responseValidationSpinner.getSelectedItemPosition()) { + case 0: + model.validationMode = ValidationMode.STATUS_CODE; + model.validationContent = null; + break; + case 1: + model.validationMode = ValidationMode.TERM_SEARCH; + model.validationContent = ((EditText) findViewById(R.id.responseValidationSearchTerm)).getText().toString().trim(); + break; + case 2: + model.validationMode = ValidationMode.JAVASCRIPT; + model.validationContent = ((EditText) findViewById(R.id.responseValidationScriptInput)).getText().toString().trim(); + break; + } + + setResult(RESULT_OK, new Intent() + .putExtra("model", model)); + finish(); + overridePendingTransition(R.anim.fade_out, R.anim.fade_out); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt deleted file mode 100644 index 3220567..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui - -import android.content.res.Configuration -import android.os.Build -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import com.afollestad.nocknock.R -import com.afollestad.nocknock.koin.PREF_DARK_MODE -import com.afollestad.nocknock.ui.NightMode.DISABLED -import com.afollestad.nocknock.ui.NightMode.ENABLED -import com.afollestad.nocknock.ui.NightMode.UNKNOWN -import com.afollestad.nocknock.utilities.rx.attachLifecycle -import com.afollestad.rxkprefs.Pref -import org.koin.android.ext.android.inject -import timber.log.Timber.d as log - -/** @author Aidan Follestad (afollestad) */ -abstract class DarkModeSwitchActivity : AppCompatActivity() { - - private var isDarkModeEnabled: Boolean = false - private val darkModePref by inject>(name = PREF_DARK_MODE) - - override fun onCreate(savedInstanceState: Bundle?) { - isDarkModeEnabled = isDarkMode() - setTheme(themeRes()) - super.onCreate(savedInstanceState) - - if (getCurrentNightMode() == UNKNOWN) { - darkModePref.observe() - .filter { it != isDarkModeEnabled } - .subscribe { - log("Theme changed, recreating Activity.") - recreate() - } - .attachLifecycle(this) - } - } - - protected fun getCurrentNightMode(): NightMode { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - return UNKNOWN - } - return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { - Configuration.UI_MODE_NIGHT_YES -> return ENABLED - Configuration.UI_MODE_NIGHT_NO -> return DISABLED - else -> UNKNOWN - } - } - - protected fun isDarkMode(): Boolean { - return when (getCurrentNightMode()) { - ENABLED -> true - DISABLED -> false - else -> darkModePref.get() - } - } - - protected fun toggleDarkMode() = setDarkMode(!isDarkMode()) - - private fun setDarkMode(darkMode: Boolean) = darkModePref.set(darkMode) - - private fun themeRes() = if (isDarkMode()) { - R.style.AppTheme_Dark - } else { - R.style.AppTheme - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.java b/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.java new file mode 100644 index 0000000..88f8ba8 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.java @@ -0,0 +1,326 @@ +package com.afollestad.nocknock.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.annotation.SuppressLint; +import android.app.ActivityOptions; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.Path; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.support.design.widget.FloatingActionButton; +import android.support.v4.app.NotificationManagerCompat; +import android.support.v4.content.ContextCompat; +import android.support.v4.widget.SwipeRefreshLayout; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.Html; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.animation.PathInterpolator; +import android.widget.TextView; + +import com.afollestad.bridge.Bridge; +import com.afollestad.inquiry.Inquiry; +import com.afollestad.materialdialogs.MaterialDialog; +import com.afollestad.nocknock.R; +import com.afollestad.nocknock.adapter.ServerAdapter; +import com.afollestad.nocknock.api.ServerModel; +import com.afollestad.nocknock.api.ValidationMode; +import com.afollestad.nocknock.dialogs.AboutDialog; +import com.afollestad.nocknock.services.CheckService; +import com.afollestad.nocknock.util.AlarmUtil; +import com.afollestad.nocknock.util.MathUtil; +import com.afollestad.nocknock.views.DividerItemDecoration; + +public class MainActivity extends AppCompatActivity implements SwipeRefreshLayout.OnRefreshListener, View.OnClickListener, ServerAdapter.ClickListener { + + private final static int ADD_SITE_RQ = 6969; + private final static int VIEW_SITE_RQ = 6923; + public final static String DB_NAME = "nock_nock"; + public final static String SITES_TABLE_NAME_OLD = "sites"; + public final static String SITES_TABLE_NAME = "site_models"; + + private FloatingActionButton mFab; + private RecyclerView mList; + private ServerAdapter mAdapter; + private TextView mEmptyText; + private SwipeRefreshLayout mRefreshLayout; + + private ObjectAnimator mFabAnimator; + private float mOrigFabX; + private float mOrigFabY; + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.v("MainActivity", "Received " + intent.getAction()); + if (CheckService.ACTION_RUNNING.equals(intent.getAction())) { + if (mRefreshLayout != null) + mRefreshLayout.setRefreshing(false); + } else { + final ServerModel model = (ServerModel) intent.getSerializableExtra("model"); + if (mAdapter != null && mList != null && model != null) { + mList.post(() -> mAdapter.update(model)); + } + } + } + }; + + @SuppressLint("CommitPrefEdits") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + mAdapter = new ServerAdapter(this); + mEmptyText = (TextView) findViewById(R.id.emptyText); + + mList = (RecyclerView) findViewById(R.id.list); + mList.setLayoutManager(new LinearLayoutManager(this)); + mList.setAdapter(mAdapter); + mList.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST)); + + mRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipeRefreshLayout); + mRefreshLayout.setOnRefreshListener(this); + mRefreshLayout.setColorSchemeColors(ContextCompat.getColor(this, R.color.md_green), + ContextCompat.getColor(this, R.color.md_yellow), + ContextCompat.getColor(this, R.color.md_red)); + + mFab = (FloatingActionButton) findViewById(R.id.fab); + mFab.setOnClickListener(this); + + Inquiry.newInstance(this, DB_NAME).build(); + Bridge.config() + .defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)"); + + final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this); + if (!sp.getBoolean("migrated_db", false)) { + final Inquiry mdb = Inquiry.newInstance(this, DB_NAME) + .instanceName("migrate_db") + .build(false); + final ServerModel[] models = Inquiry.get(this) + .selectFrom(SITES_TABLE_NAME_OLD, ServerModel.class) + .projection("_id", "name", "url", "status", "checkInterval", "lastCheck", "reason") + .all(); + if (models != null) { + Log.d("SiteMigration", "Migrating " + models.length + " sites to the new table."); + for (ServerModel model : models) { + model.validationMode = ValidationMode.STATUS_CODE; + model.validationContent = null; + } + //noinspection CheckResult + mdb.insertInto(SITES_TABLE_NAME, ServerModel.class) + .values(models) + .run(); + mdb.dropTable(SITES_TABLE_NAME_OLD); + } + sp.edit().putBoolean("migrated_db", true).commit(); + } + } + + private void showRefreshTutorial() { + if (mAdapter.getItemCount() == 0) return; + final SharedPreferences pr = PreferenceManager.getDefaultSharedPreferences(this); + if (pr.getBoolean("shown_swipe_refresh_tutorial", false)) return; + + mFab.hide(); + final View tutorialView = findViewById(R.id.swipeRefreshTutorial); + tutorialView.setVisibility(View.VISIBLE); + tutorialView.setAlpha(0f); + tutorialView.animate().cancel(); + tutorialView.animate().setDuration(300).alpha(1f).start(); + + findViewById(R.id.understoodBtn).setOnClickListener(view -> { + view.setOnClickListener(null); + findViewById(R.id.swipeRefreshTutorial).setVisibility(View.GONE); + pr.edit().putBoolean("shown_swipe_refresh_tutorial", true).commit(); + mFab.show(); + }); + } + + @Override + protected void onResume() { + super.onResume(); + CheckService.isAppOpen(this, true); + + try { + final IntentFilter filter = new IntentFilter(); + filter.addAction(CheckService.ACTION_CHECK_UPDATE); + filter.addAction(CheckService.ACTION_RUNNING); + registerReceiver(mReceiver, filter); + } catch (Throwable t) { + t.printStackTrace(); + } + + refreshModels(); + } + + @Override + protected void onPause() { + super.onPause(); + CheckService.isAppOpen(this, false); + + if (isFinishing()) { + Inquiry.destroy(this); + } + + NotificationManagerCompat.from(this).cancel(CheckService.NOTI_ID); + try { + unregisterReceiver(mReceiver); + } catch (Throwable t) { + t.printStackTrace(); + } + } + + private void refreshModels() { + mAdapter.clear(); + mEmptyText.setVisibility(View.VISIBLE); + Inquiry.get(this) + .selectFrom(SITES_TABLE_NAME, ServerModel.class) + .all(this::setModels); + } + + private void setModels(ServerModel[] models) { + mAdapter.set(models); + mEmptyText.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); + AlarmUtil.setSiteChecks(this, models); + showRefreshTutorial(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_main, menu); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.about) { + AboutDialog.show(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onRefresh() { + if (CheckService.isRunning(this)) { + mRefreshLayout.setRefreshing(false); + return; + } + startService(new Intent(this, CheckService.class)); + } + + // FAB clicked + @Override + public void onClick(View view) { + mOrigFabX = mFab.getX(); + mOrigFabY = mFab.getY(); + final Path curve = MathUtil.bezierCurve(mFab, mList); + if (mFabAnimator != null) + mFabAnimator.cancel(); + mFabAnimator = ObjectAnimator.ofFloat(view, View.X, View.Y, curve); + mFabAnimator.setInterpolator(new PathInterpolator(.5f, .5f)); + mFabAnimator.setDuration(300); + mFabAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + startActivityForResult(new Intent(MainActivity.this, AddSiteActivity.class) + .putExtra("fab_x", mOrigFabX) + .putExtra("fab_y", mOrigFabY) + .putExtra("fab_size", mFab.getMeasuredWidth()) + .addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION), ADD_SITE_RQ); + mFab.postDelayed(() -> { + mFab.setX(mOrigFabX); + mFab.setY(mOrigFabY); + }, 600); + } + }); + mFabAnimator.start(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK) { + final ServerModel model = (ServerModel) data.getSerializableExtra("model"); + if (requestCode == ADD_SITE_RQ) { + mAdapter.add(model); + mEmptyText.setVisibility(View.GONE); + Inquiry.get(this).insertInto(SITES_TABLE_NAME, ServerModel.class) + .values(model) + .run(inserted -> { + AlarmUtil.setSiteChecks(MainActivity.this, model); + checkSite(MainActivity.this, model); + }); + } else if (requestCode == VIEW_SITE_RQ) { + mAdapter.update(model); + AlarmUtil.setSiteChecks(MainActivity.this, model); + checkSite(MainActivity.this, model); + } + } + } + + public static void removeSite(final Context context, final ServerModel model, final Runnable onRemoved) { + new MaterialDialog.Builder(context) + .title(R.string.remove_site) + .content(Html.fromHtml(context.getString(R.string.remove_site_prompt, model.name))) + .positiveText(R.string.remove) + .negativeText(android.R.string.cancel) + .onPositive((dialog, which) -> { + AlarmUtil.cancelSiteChecks(context, model); + final NotificationManagerCompat nm = NotificationManagerCompat.from(context); + nm.cancel(model.url, CheckService.NOTI_ID); + //noinspection CheckResult + final Inquiry rinq = Inquiry.newInstance(context, DB_NAME) + .instanceName("remove_site") + .build(false); + //noinspection CheckResult + rinq.deleteFrom(SITES_TABLE_NAME, ServerModel.class) + .where("_id = ?", model.id) + .run(); + rinq.destroyInstance(); + if (onRemoved != null) + onRemoved.run(); + }).show(); + } + + public static void checkSite(Context context, ServerModel model) { + context.startService(new Intent(context, CheckService.class) + .putExtra(CheckService.MODEL_ID, model.id)); + } + + @Override + public void onSiteSelected(final int index, final ServerModel model, boolean longClick) { + if (longClick) { + new MaterialDialog.Builder(this) + .title(R.string.options) + .items(R.array.site_long_options) + .negativeText(android.R.string.cancel) + .itemsCallback((dialog, itemView, which, text) -> { + if (which == 0) { + checkSite(MainActivity.this, model); + } else { + removeSite(MainActivity.this, model, () -> { + mAdapter.remove(index); + mEmptyText.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); + }); + } + }).show(); + } else { + startActivityForResult(new Intent(this, ViewSiteActivity.class) + .putExtra("model", model), VIEW_SITE_RQ, + ActivityOptions.makeSceneTransitionAnimation(this).toBundle()); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt deleted file mode 100644 index 2930fea..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui - -/** @author Aidan Follestad (@afollestad) */ -enum class NightMode { - /** Night mode is on at the system level. */ - ENABLED, - /** Night mode is off at the system level. */ - DISABLED, - /** We don't know about night mode, fallback to custom impl. */ - UNKNOWN -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt deleted file mode 100644 index 750173a..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import org.jetbrains.annotations.TestOnly - -/** @author Aidan Follestad (@afollestad) */ -abstract class ScopedViewModel(mainDispatcher: CoroutineDispatcher) : ViewModel() { - - private val job = Job() - protected val scope = CoroutineScope(job + mainDispatcher) - - override fun onCleared() { - super.onCleared() - job.cancel() - } - - @TestOnly open fun destroy() = job.cancel() -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.java b/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.java new file mode 100644 index 0000000..5edbef3 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.java @@ -0,0 +1,340 @@ +package com.afollestad.nocknock.ui; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.util.Log; +import android.util.Patterns; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import com.afollestad.bridge.Bridge; +import com.afollestad.inquiry.Inquiry; +import com.afollestad.nocknock.R; +import com.afollestad.nocknock.api.ServerModel; +import com.afollestad.nocknock.api.ServerStatus; +import com.afollestad.nocknock.api.ValidationMode; +import com.afollestad.nocknock.services.CheckService; +import com.afollestad.nocknock.util.TimeUtil; +import com.afollestad.nocknock.views.StatusImageView; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * @author Aidan Follestad (afollestad) + */ +public class ViewSiteActivity extends AppCompatActivity implements View.OnClickListener, Toolbar.OnMenuItemClickListener { + + private StatusImageView iconStatus; + private EditText inputName; + private EditText inputUrl; + private EditText inputCheckInterval; + private Spinner checkIntervalSpinner; + private TextView textLastCheckResult; + private TextView textNextCheck; + private TextView textUrlWarning; + private Spinner responseValidationSpinner; + + private ServerModel mModel; + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.v("ViewSiteActivity", "Received " + intent.getAction()); + final ServerModel model = (ServerModel) intent.getSerializableExtra("model"); + if (model != null) { + mModel = model; + update(); + } + } + }; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_viewsite); + + final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + toolbar.setNavigationOnClickListener(view -> finish()); + toolbar.inflateMenu(R.menu.menu_viewsite); + toolbar.setOnMenuItemClickListener(this); + + iconStatus = (StatusImageView) findViewById(R.id.iconStatus); + inputName = (EditText) findViewById(R.id.inputName); + inputUrl = (EditText) findViewById(R.id.inputUrl); + textUrlWarning = (TextView) findViewById(R.id.textUrlWarning); + inputCheckInterval = (EditText) findViewById(R.id.checkIntervalInput); + checkIntervalSpinner = (Spinner) findViewById(R.id.checkIntervalSpinner); + textLastCheckResult = (TextView) findViewById(R.id.textLastCheckResult); + textNextCheck = (TextView) findViewById(R.id.textNextCheck); + responseValidationSpinner = (Spinner) findViewById(R.id.responseValidationMode); + + ArrayAdapter 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 validationOptionsAdapter = new ArrayAdapter<>(this, R.layout.list_item_spinner, + getResources().getStringArray(R.array.response_validation_options)); + validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown); + responseValidationSpinner.setAdapter(validationOptionsAdapter); + responseValidationSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView adapterView, View view, int i, long l) { + final View searchTerm = findViewById(R.id.responseValidationSearchTerm); + final View javascript = findViewById(R.id.responseValidationScript); + final TextView modeDesc = (TextView) findViewById(R.id.validationModeDescription); + + searchTerm.setVisibility(i == 1 ? View.VISIBLE : View.GONE); + javascript.setVisibility(i == 2 ? View.VISIBLE : View.GONE); + + switch (i) { + case 0: + modeDesc.setText(R.string.validation_mode_status_desc); + break; + case 1: + modeDesc.setText(R.string.validation_mode_term_desc); + break; + case 2: + modeDesc.setText(R.string.validation_mode_javascript_desc); + break; + } + } + + @Override + public void onNothingSelected(AdapterView adapterView) { + } + }); + + mModel = (ServerModel) getIntent().getSerializableExtra("model"); + update(); + + Bridge.config() + .defaultHeader("User-Agent", getString(R.string.app_name) + " (Android)"); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (intent != null && intent.hasExtra("model")) { + mModel = (ServerModel) intent.getSerializableExtra("model"); + update(); + } + } + + @SuppressLint({"SetTextI18n", "SwitchIntDef"}) + private void update() { + final SimpleDateFormat df = new SimpleDateFormat("MMMM dd, hh:mm:ss a", Locale.getDefault()); + + iconStatus.setStatus(mModel.status); + inputName.setText(mModel.name); + inputUrl.setText(mModel.url); + + if (mModel.lastCheck == 0) { + textLastCheckResult.setText(R.string.none); + } else { + switch (mModel.status) { + case ServerStatus.CHECKING: + textLastCheckResult.setText(R.string.checking_status); + break; + case ServerStatus.ERROR: + textLastCheckResult.setText(mModel.reason); + break; + case ServerStatus.OK: + textLastCheckResult.setText(R.string.everything_checks_out); + break; + case ServerStatus.WAITING: + textLastCheckResult.setText(R.string.waiting); + break; + } + } + + if (mModel.checkInterval == 0) { + textNextCheck.setText(R.string.none_turned_off); + inputCheckInterval.setText(""); + checkIntervalSpinner.setSelection(0); + } else { + long lastCheck = mModel.lastCheck; + if (lastCheck == 0) lastCheck = System.currentTimeMillis(); + textNextCheck.setText(df.format(new Date(lastCheck + mModel.checkInterval))); + + if (mModel.checkInterval >= TimeUtil.WEEK) { + inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.WEEK)))); + checkIntervalSpinner.setSelection(3); + } else if (mModel.checkInterval >= TimeUtil.DAY) { + inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.DAY)))); + checkIntervalSpinner.setSelection(2); + } else if (mModel.checkInterval >= TimeUtil.HOUR) { + inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.HOUR)))); + checkIntervalSpinner.setSelection(1); + } else if (mModel.checkInterval >= TimeUtil.MINUTE) { + inputCheckInterval.setText(Integer.toString((int) Math.ceil(((float) mModel.checkInterval / (float) TimeUtil.MINUTE)))); + checkIntervalSpinner.setSelection(0); + } else { + inputCheckInterval.setText("0"); + checkIntervalSpinner.setSelection(0); + } + } + + responseValidationSpinner.setSelection(mModel.validationMode - 1); + switch (mModel.validationMode) { + case ValidationMode.TERM_SEARCH: + ((TextView) findViewById(R.id.responseValidationSearchTerm)).setText(mModel.validationContent); + break; + case ValidationMode.JAVASCRIPT: + ((TextView) findViewById(R.id.responseValidationScriptInput)).setText(mModel.validationContent); + break; + } + + findViewById(R.id.doneBtn).setOnClickListener(this); + } + + @Override + protected void onResume() { + super.onResume(); + try { + final IntentFilter filter = new IntentFilter(); + filter.addAction(CheckService.ACTION_CHECK_UPDATE); + // filter.addAction(CheckService.ACTION_RUNNING); + registerReceiver(mReceiver, filter); + } catch (Throwable t) { + t.printStackTrace(); + } + } + + @Override + protected void onPause() { + super.onPause(); + try { + unregisterReceiver(mReceiver); + } catch (Throwable t) { + t.printStackTrace(); + } + } + + private void performSave(boolean withValidation) { + mModel.name = inputName.getText().toString().trim(); + mModel.url = inputUrl.getText().toString().trim(); + mModel.status = ServerStatus.WAITING; + + if (withValidation && mModel.name.isEmpty()) { + inputName.setError(getString(R.string.please_enter_name)); + return; + } else { + inputName.setError(null); + } + + if (withValidation && mModel.url.isEmpty()) { + inputUrl.setError(getString(R.string.please_enter_url)); + return; + } else { + inputUrl.setError(null); + if (withValidation && !Patterns.WEB_URL.matcher(mModel.url).find()) { + inputUrl.setError(getString(R.string.please_enter_valid_url)); + return; + } else { + final Uri uri = Uri.parse(mModel.url); + if (uri.getScheme() == null) + mModel.url = "http://" + mModel.url; + } + } + + String intervalStr = inputCheckInterval.getText().toString().trim(); + if (intervalStr.isEmpty()) intervalStr = "0"; + mModel.checkInterval = Integer.parseInt(intervalStr); + + switch (checkIntervalSpinner.getSelectedItemPosition()) { + case 0: // minutes + mModel.checkInterval *= (60 * 1000); + break; + case 1: // hours + mModel.checkInterval *= (60 * 60 * 1000); + break; + case 2: // days + mModel.checkInterval *= (60 * 60 * 24 * 1000); + break; + default: // weeks + mModel.checkInterval *= (60 * 60 * 24 * 7 * 1000); + break; + } + + mModel.lastCheck = System.currentTimeMillis() - mModel.checkInterval; + + switch (responseValidationSpinner.getSelectedItemPosition()) { + case 0: + mModel.validationMode = ValidationMode.STATUS_CODE; + mModel.validationContent = null; + break; + case 1: + mModel.validationMode = ValidationMode.TERM_SEARCH; + mModel.validationContent = ((EditText) findViewById(R.id.responseValidationSearchTerm)).getText().toString().trim(); + break; + case 2: + mModel.validationMode = ValidationMode.JAVASCRIPT; + mModel.validationContent = ((EditText) findViewById(R.id.responseValidationScriptInput)).getText().toString().trim(); + break; + } + + final Inquiry inq = Inquiry.newInstance(this, MainActivity.DB_NAME) + .build(false); + //noinspection CheckResult + inq.update(MainActivity.SITES_TABLE_NAME, ServerModel.class) + .where("_id = ?", mModel.id) + .values(mModel) + .run(); + inq.destroyInstance(); + } + + // Save button + @Override + public void onClick(View view) { + performSave(true); + setResult(RESULT_OK, new Intent().putExtra("model", mModel)); + finish(); + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.refresh: + performSave(false); + MainActivity.checkSite(this, mModel); + return true; + case R.id.remove: + MainActivity.removeSite(this, mModel, this::finish); + return true; + } + return false; + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt deleted file mode 100644 index e15a29f..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.addsite - -import android.annotation.SuppressLint -import android.content.Intent -import android.content.Intent.ACTION_OPEN_DOCUMENT -import android.content.Intent.CATEGORY_OPENABLE -import android.os.Bundle -import android.widget.ArrayAdapter -import androidx.lifecycle.Observer -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.ValidationMode -import com.afollestad.nocknock.ui.DarkModeSwitchActivity -import com.afollestad.nocknock.ui.viewsite.KEY_SITE -import com.afollestad.nocknock.utilities.ext.onTextChanged -import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection -import com.afollestad.nocknock.utilities.livedata.distinct -import com.afollestad.nocknock.viewcomponents.ext.dimenFloat -import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition -import com.afollestad.nocknock.viewcomponents.ext.onScroll -import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData -import com.afollestad.nocknock.viewcomponents.livedata.toViewText -import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility -import com.afollestad.vvalidator.form -import com.afollestad.vvalidator.form.Form -import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout -import kotlinx.android.synthetic.main.activity_addsite.headersLayout -import kotlinx.android.synthetic.main.activity_addsite.inputName -import kotlinx.android.synthetic.main.activity_addsite.inputTags -import kotlinx.android.synthetic.main.activity_addsite.inputUrl -import kotlinx.android.synthetic.main.activity_addsite.loadingProgress -import kotlinx.android.synthetic.main.activity_addsite.responseTimeoutInput -import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode -import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm -import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout -import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout -import kotlinx.android.synthetic.main.activity_addsite.scrollView -import kotlinx.android.synthetic.main.activity_addsite.sslCertificateBrowse -import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput -import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning -import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription -import kotlinx.android.synthetic.main.include_app_bar.toolbar -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar -import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle - -/** @author Aidan Follestad (@afollestad) */ -class AddSiteActivity : DarkModeSwitchActivity() { - companion object { - private const val SELECT_CERT_FILE_RQ = 23 - } - - private val viewModel by viewModel() - private lateinit var validationForm: Form - - @SuppressLint("SetTextI18n") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_addsite) - setupUi() - setupValidation() - - lifecycle.addObserver(viewModel) - - // Populate view model with initial data - val model = intent.getSerializableExtra(KEY_SITE) as? Site - model?.let { viewModel.prePopulateFromModel(model) } - - // Loading - loadingProgress.observe(this, viewModel.onIsLoading()) - - // Name - inputName.attachLiveData(this, viewModel.name) - - // Tags - inputTags.attachLiveData(this, viewModel.tags) - - // Url - inputUrl.attachLiveData(this, viewModel.url) - viewModel.onUrlWarningVisibility() - .toViewVisibility(this, textUrlWarning) - - // Timeout - responseTimeoutInput.attachLiveData(this, viewModel.timeout) - - // Validation mode - responseValidationMode.attachLiveData( - lifecycleOwner = this, - data = viewModel.validationMode, - outTransformer = { ValidationMode.fromIndex(it) }, - inTransformer = { it.toIndex() } - ) - viewModel.onValidationModeDescription() - .toViewText(this, validationModeDescription) - - // Validation search term - responseValidationSearchTerm.attachLiveData( - lifecycleOwner = this, - data = viewModel.validationSearchTerm, - pullInChanges = false - ) - viewModel.onValidationSearchTermVisibility() - .toViewVisibility(this, responseValidationSearchTerm) - - // SSL certificate - sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } - viewModel.certificateUri.distinct() - .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - - // Headers - headersLayout.attach(viewModel.headers) - } - - private fun setupUi() { - toolbarTitle.setText(R.string.add_site) - toolbar.run { - inflateMenu(R.menu.menu_addsite) - setNavigationIcon(R.drawable.ic_action_close) - setNavigationOnClickListener { finish() } - } - - val validationOptionsAdapter = ArrayAdapter( - this, - R.layout.list_item_spinner, - resources.getStringArray(R.array.response_validation_options) - ) - validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) - responseValidationMode.adapter = validationOptionsAdapter - - scrollView.onScroll { - appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) { - appToolbar.dimenFloat(R.dimen.default_elevation) - } else { - 0f - } - } - - // SSL certificate - sslCertificateBrowse.setOnClickListener { - val intent = Intent(ACTION_OPEN_DOCUMENT).apply { - addCategory(CATEGORY_OPENABLE) - type = "*/*" - } - startActivityForResult(intent, SELECT_CERT_FILE_RQ) - } - } - - private fun setupValidation() { - validationForm = form { - input(inputName, name = "Name") { - isNotEmpty().description(R.string.please_enter_name) - } - input(inputUrl, name = "URL") { - isNotEmpty().description(R.string.please_enter_url) - isUrl().description(R.string.please_enter_valid_url) - } - input(responseTimeoutInput, name = "Timeout", optional = true) { - isNumber().greaterThan(0) - .description(R.string.please_enter_networkTimeout) - } - input(responseValidationSearchTerm, name = "Search term") { - conditional(responseValidationSearchTerm.isVisibleCondition()) { - isNotEmpty().description(R.string.please_enter_search_term) - } - } - input(sslCertificateInput, name = "Certificate Path", optional = true) { - isUri().hasScheme("file", "content") - .that { it.host != null } - .description(R.string.please_enter_validCertUri) - } - submitWith(toolbar.menu, R.id.commit) { - viewModel.commit { - setResult(RESULT_OK) - finish() - } - } - } - - // Validation script - scriptInputLayout.attach( - codeData = viewModel.validationScript, - visibility = viewModel.onValidationScriptVisibility(), - form = validationForm - ) - - // Check interval - checkIntervalLayout.attach( - valueData = viewModel.checkIntervalValue, - multiplierData = viewModel.checkIntervalUnit, - form = validationForm - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes, - form = validationForm - ) - } - - override fun onResume() { - super.onResume() - appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { - appToolbar.dimenFloat(R.dimen.default_elevation) - } else { - 0f - } - } - - override fun onActivityResult( - requestCode: Int, - resultCode: Int, - resultData: Intent? - ) { - super.onActivityResult(requestCode, resultCode, resultData) - if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) { - sslCertificateInput.setText(resultData?.data?.toString() ?: "") - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt deleted file mode 100644 index d7d8ed5..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.addsite - -import androidx.annotation.CheckResult -import androidx.annotation.VisibleForTesting -import androidx.annotation.VisibleForTesting.PRIVATE -import androidx.lifecycle.Lifecycle.Event.ON_START -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.OnLifecycleEvent -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.model.Header -import com.afollestad.nocknock.data.model.RetryPolicy -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.SiteSettings -import com.afollestad.nocknock.data.model.Status.WAITING -import com.afollestad.nocknock.data.model.ValidationMode -import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.model.ValidationResult -import com.afollestad.nocknock.data.putSite -import com.afollestad.nocknock.engine.validation.ValidationExecutor -import com.afollestad.nocknock.ui.ScopedViewModel -import com.afollestad.nocknock.utilities.ext.MINUTE -import com.afollestad.nocknock.utilities.livedata.map -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.HttpUrl -import java.lang.System.currentTimeMillis - -/** @author Aidan Follestad (@afollestad) */ -class AddSiteViewModel( - private val database: AppDatabase, - private val validationManager: ValidationExecutor, - mainDispatcher: CoroutineDispatcher, - private val ioDispatcher: CoroutineDispatcher -) : ScopedViewModel(mainDispatcher), LifecycleObserver { - - // Public properties - val name = MutableLiveData() - val tags = MutableLiveData() - val url = MutableLiveData() - val timeout = MutableLiveData() - val validationMode = MutableLiveData() - val validationSearchTerm = MutableLiveData() - val validationScript = MutableLiveData() - val checkIntervalValue = MutableLiveData() - val checkIntervalUnit = MutableLiveData() - val retryPolicyTimes = MutableLiveData() - val retryPolicyMinutes = MutableLiveData() - val headers = MutableLiveData>() - val certificateUri = MutableLiveData() - - @OnLifecycleEvent(ON_START) - fun setDefaults() { - timeout.value = 10000 - validationMode.value = STATUS_CODE - checkIntervalValue.value = 0 - checkIntervalUnit.value = MINUTE - retryPolicyMinutes.value = 0 - retryPolicyMinutes.value = 0 - tags.value = "" - headers.value = emptyList() - } - - private val isLoading = MutableLiveData() - - @CheckResult fun onIsLoading(): LiveData = isLoading - - @CheckResult fun onUrlWarningVisibility(): LiveData { - return url.map { - val parsed = HttpUrl.parse(it) - return@map it.isNotEmpty() && parsed == null - } - } - - @CheckResult fun onValidationModeDescription(): LiveData { - return validationMode.map { - when (it!!) { - STATUS_CODE -> R.string.validation_mode_status_desc - TERM_SEARCH -> R.string.validation_mode_term_desc - JAVASCRIPT -> R.string.validation_mode_javascript_desc - } - } - } - - @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - - @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } - - // Actions - fun commit(done: () -> Unit) { - scope.launch { - val newModel = generateDbModel() ?: return@launch - isLoading.value = true - - val storedModel = withContext(ioDispatcher) { - database.putSite(newModel) - } - validationManager.scheduleValidation( - site = storedModel, - rightNow = true, - cancelPrevious = true - ) - - isLoading.value = false - done() - } - } - - // Utilities - @VisibleForTesting(otherwise = PRIVATE) - fun getCheckIntervalMs(): Long { - val value = checkIntervalValue.value ?: return 0 - val unit = checkIntervalUnit.value ?: return 0 - return value * unit - } - - @VisibleForTesting(otherwise = PRIVATE) - fun getValidationArgs(): String? { - return when (validationMode.value) { - TERM_SEARCH -> validationSearchTerm.value - JAVASCRIPT -> validationScript.value - else -> null - } - } - - private fun generateDbModel(): Site? { - val timeout = timeout.value ?: 10_000 - val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" - - val newSettings = SiteSettings( - validationIntervalMs = getCheckIntervalMs(), - validationMode = validationMode.value!!, - validationArgs = getValidationArgs(), - networkTimeout = timeout, - disabled = false, - certificate = certificateUri.value?.toString() - ) - - val newLastResult = ValidationResult( - timestampMs = currentTimeMillis(), - status = WAITING, - reason = null - ) - - val retryPolicyTimes = retryPolicyTimes.value ?: 0 - val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 - val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { - RetryPolicy( - count = retryPolicyTimes, - minutes = retryPolicyMinutes - ) - } else { - null - } - - return Site( - id = 0, - name = name.value!!.trim(), - url = url.value!!.trim(), - tags = cleanedTags, - settings = newSettings, - lastResult = newLastResult, - retryPolicy = newRetryPolicy, - headers = headers.value ?: emptyList() - ) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt deleted file mode 100644 index c524555..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.addsite - -import com.afollestad.nocknock.data.model.RetryPolicy -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.utilities.ext.DAY -import com.afollestad.nocknock.utilities.ext.HOUR -import com.afollestad.nocknock.utilities.ext.MINUTE -import com.afollestad.nocknock.utilities.ext.WEEK -import kotlin.math.ceil - -fun AddSiteViewModel.prePopulateFromModel(site: Site) { - val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!") - - name.value = site.name - tags.value = site.tags - url.value = site.url - timeout.value = settings.networkTimeout - - validationMode.value = settings.validationMode - when (settings.validationMode) { - TERM_SEARCH -> { - validationSearchTerm.value = settings.validationArgs - validationScript.value = null - } - JAVASCRIPT -> { - validationSearchTerm.value = null - validationScript.value = settings.validationArgs - } - else -> { - validationSearchTerm.value = null - validationScript.value = null - } - } - - setCheckInterval(settings.validationIntervalMs) - setRetryPolicy(site.retryPolicy) - headers.value = site.headers -} - -private fun AddSiteViewModel.setCheckInterval(interval: Long) { - when { - interval >= WEEK -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, WEEK) - checkIntervalUnit.value = WEEK - } - interval >= DAY -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, DAY) - checkIntervalUnit.value = DAY - } - interval >= HOUR -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, HOUR) - checkIntervalUnit.value = HOUR - } - interval >= MINUTE -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, MINUTE) - checkIntervalUnit.value = MINUTE - } - else -> { - checkIntervalValue.value = 0 - checkIntervalUnit.value = MINUTE - } - } -} - -private fun AddSiteViewModel.setRetryPolicy(policy: RetryPolicy?) { - if (policy == null) return - retryPolicyTimes.value = policy.count - retryPolicyMinutes.value = policy.minutes -} - -private fun getIntervalFromUnit( - millis: Long, - unit: Long -): Int { - val intervalFloat = millis.toFloat() - val byFloat = unit.toFloat() - return ceil(intervalFloat / byFloat).toInt() -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt deleted file mode 100644 index aec76d8..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.main - -import android.content.Intent -import android.os.Bundle -import androidx.lifecycle.Observer -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.materialdialogs.list.listItems -import com.afollestad.nocknock.R -import com.afollestad.nocknock.adapter.SiteAdapter -import com.afollestad.nocknock.adapter.TagAdapter -import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.dialogs.AboutDialog -import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.ui.DarkModeSwitchActivity -import com.afollestad.nocknock.ui.NightMode.UNKNOWN -import com.afollestad.nocknock.utilities.providers.IntentProvider -import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility -import kotlinx.android.synthetic.main.activity_main.fab -import kotlinx.android.synthetic.main.activity_main.list -import kotlinx.android.synthetic.main.activity_main.loadingProgress -import kotlinx.android.synthetic.main.include_app_bar.toolbar -import kotlinx.android.synthetic.main.include_empty_view.emptyText -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlinx.android.synthetic.main.activity_main.tags_list as tagsList - -/** @author Aidan Follestad (@afollestad) */ -class MainActivity : DarkModeSwitchActivity() { - - private val notificationManager by inject() - private val intentProvider by inject() - - internal val viewModel by viewModel() - - private lateinit var siteAdapter: SiteAdapter - private lateinit var tagAdapter: TagAdapter - - private val statusUpdateReceiver by lazy { - StatusUpdateIntentReceiver(application, intentProvider) { - viewModel.postSiteUpdate(it) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - setupUi() - - notificationManager.createChannels() - - lifecycle.run { - addObserver(viewModel) - addObserver(statusUpdateReceiver) - } - - viewModel.onSites() - .observe(this, Observer { siteAdapter.set(it) }) - viewModel.onEmptyTextVisibility() - .toViewVisibility(this, emptyText) - viewModel.onTags() - .observe(this, Observer { tagAdapter.set(it) }) - viewModel.onTagsListVisibility() - .toViewVisibility(this, tagsList) - loadingProgress.observe(this, viewModel.onIsLoading()) - - processIntent(intent) - } - - private fun setupUi() { - toolbar.run { - inflateMenu(R.menu.menu_main) - menu.findItem(R.id.dark_mode) - .apply { - if (getCurrentNightMode() == UNKNOWN) { - isChecked = isDarkMode() - } else { - isVisible = false - } - } - setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.about -> AboutDialog.show(this@MainActivity) - R.id.dark_mode -> toggleDarkMode() - } - return@setOnMenuItemClickListener true - } - } - - siteAdapter = SiteAdapter(this::onSiteSelected) - list.run { - layoutManager = LinearLayoutManager(this@MainActivity) - adapter = siteAdapter - addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL)) - } - - tagAdapter = TagAdapter(viewModel::onTagSelection) - tagsList.run { - layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false) - adapter = tagAdapter - } - - fab.setOnClickListener { addSite() } - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - intent?.let(::processIntent) - } - - private fun onSiteSelected( - model: Site, - longClick: Boolean - ) { - if (longClick) { - MaterialDialog(this).show { - title(R.string.options) - listItems(R.array.site_long_options) { _, i, _ -> - when (i) { - 0 -> viewModel.refreshSite(model) - 1 -> addSiteForDuplication(model) - 2 -> maybeRemoveSite(model) - } - } - } - } else { - viewSite(model) - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt deleted file mode 100644 index e11ca08..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.main - -import android.content.Intent -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.toHtml -import com.afollestad.nocknock.ui.addsite.AddSiteActivity -import com.afollestad.nocknock.ui.viewsite.KEY_SITE -import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity -import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL - -internal const val VIEW_SITE_RQ = 6923 -internal const val ADD_SITE_RQ = 6969 - -// ADD - -internal fun MainActivity.addSite() { - startActivityForResult(intentToAdd(), ADD_SITE_RQ) -} - -internal fun MainActivity.addSiteForDuplication(site: Site) { - startActivityForResult(intentToAdd(site), ADD_SITE_RQ) -} - -private fun MainActivity.intentToAdd(model: Site? = null) = - Intent(this, AddSiteActivity::class.java).apply { - model?.let { putExtra(KEY_SITE, it) } - } - -// VIEW - -internal fun MainActivity.viewSite(model: Site) { - startActivityForResult(intentToView(model), VIEW_SITE_RQ) -} - -private fun MainActivity.intentToView(model: Site) = - Intent(this, ViewSiteActivity::class.java).apply { - putExtra(KEY_SITE, model) - } - -// MISC - -internal fun MainActivity.maybeRemoveSite(model: Site) { - MaterialDialog(this).show { - title(R.string.remove_site) - message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml()) - positiveButton(R.string.remove) { viewModel.removeSite(model) } - negativeButton(android.R.string.cancel) - } -} - -internal fun MainActivity.processIntent(intent: Intent) { - if (intent.hasExtra(KEY_VIEW_NOTIFICATION_MODEL)) { - val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as Site - viewSite(model) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt deleted file mode 100644 index bad64fc..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.main - -import androidx.annotation.CheckResult -import androidx.lifecycle.Lifecycle.Event.ON_RESUME -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.OnLifecycleEvent -import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.allSites -import com.afollestad.nocknock.data.deleteSite -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.engine.validation.ValidationExecutor -import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.ui.ScopedViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** @author Aidan Follestad (@afollestad) */ -class MainViewModel( - private val database: AppDatabase, - private val notificationManager: NockNotificationManager, - private val validationManager: ValidationExecutor, - mainDispatcher: CoroutineDispatcher, - private val ioDispatcher: CoroutineDispatcher -) : ScopedViewModel(mainDispatcher), LifecycleObserver { - - private val sites = MutableLiveData>() - private val isLoading = MutableLiveData() - private val emptyTextVisibility = MutableLiveData() - private val tags = MutableLiveData>() - private val tagsListVisibility = MutableLiveData() - - @CheckResult fun onSites(): LiveData> = sites - - @CheckResult fun onIsLoading(): LiveData = isLoading - - @CheckResult fun onEmptyTextVisibility(): LiveData = emptyTextVisibility - - @CheckResult fun onTags(): LiveData> = tags - - @CheckResult fun onTagsListVisibility(): LiveData = tagsListVisibility - - @OnLifecycleEvent(ON_RESUME) - fun onResume() = loadSites(emptyList()) - - fun onTagSelection(tags: List) = loadSites(tags) - - fun postSiteUpdate(model: Site) { - val currentSites = sites.value ?: return - val index = currentSites.indexOfFirst { it.id == model.id } - if (index == -1) return - sites.value = currentSites.toMutableList() - .apply { - this[index] = model - } - } - - fun refreshSite(model: Site) { - validationManager.scheduleValidation( - site = model, - rightNow = true, - cancelPrevious = true - ) - } - - fun removeSite(model: Site) { - validationManager.cancelScheduledValidation(model) - notificationManager.cancelStatusNotification(model) - - scope.launch { - isLoading.value = true - withContext(ioDispatcher) { database.deleteSite(model) } - - val currentSites = sites.value ?: return@launch - val index = currentSites.indexOfFirst { it.id == model.id } - isLoading.value = false - if (index == -1) return@launch - - val newSitesList = currentSites.toMutableList() - .apply { - removeAt(index) - } - sites.value = newSitesList - emptyTextVisibility.value = newSitesList.isEmpty() - } - } - - private fun loadSites(forTags: List) { - scope.launch { - notificationManager.cancelStatusNotifications() - emptyTextVisibility.value = false - isLoading.value = true - - val unfiltered = withContext(ioDispatcher) { - database.allSites() - } - var result = unfiltered - - if (forTags.isNotEmpty()) { - result = result.filter { site -> - val itemTags = site.tags.toLowerCase() - .split(",") - itemTags.any { tag -> forTags.contains(tag) } - } - } - - sites.value = result - ensureCheckJobs() - isLoading.value = false - emptyTextVisibility.value = result.isEmpty() - - val tagsValues = pullOutTags(unfiltered) - tags.value = tagsValues - tagsListVisibility.value = tagsValues.isNotEmpty() - } - } - - private suspend fun ensureCheckJobs() { - withContext(ioDispatcher) { - validationManager.ensureScheduledValidations() - } - } - - private fun pullOutTags(sites: List): List { - return mutableListOf().apply { - for (site in sites) { - val splitTags = site.tags.toLowerCase() - .split(',') - splitTags - .filter { it.isNotEmpty() } - .forEach { tag -> - if (!this.contains(tag)) { - this.add(tag) - } - } - } - sort() - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt deleted file mode 100644 index 2aa312c..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.viewsite - -import android.annotation.SuppressLint -import android.content.Intent -import android.content.Intent.ACTION_OPEN_DOCUMENT -import android.content.Intent.CATEGORY_OPENABLE -import android.os.Bundle -import android.widget.ArrayAdapter -import androidx.lifecycle.Observer -import com.afollestad.nocknock.R -import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.ValidationMode -import com.afollestad.nocknock.ui.DarkModeSwitchActivity -import com.afollestad.nocknock.utilities.ext.onTextChanged -import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection -import com.afollestad.nocknock.utilities.livedata.distinct -import com.afollestad.nocknock.utilities.providers.IntentProvider -import com.afollestad.nocknock.viewcomponents.ext.dimenFloat -import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition -import com.afollestad.nocknock.viewcomponents.ext.onScroll -import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData -import com.afollestad.nocknock.viewcomponents.livedata.toViewText -import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility -import com.afollestad.vvalidator.form -import com.afollestad.vvalidator.form.Form -import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout -import kotlinx.android.synthetic.main.activity_viewsite.headersLayout -import kotlinx.android.synthetic.main.activity_viewsite.iconStatus -import kotlinx.android.synthetic.main.activity_viewsite.inputName -import kotlinx.android.synthetic.main.activity_viewsite.inputTags -import kotlinx.android.synthetic.main.activity_viewsite.inputUrl -import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress -import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput -import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode -import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm -import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout -import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout -import kotlinx.android.synthetic.main.activity_viewsite.scrollView -import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateBrowse -import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateInput -import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult -import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck -import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning -import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription -import kotlinx.android.synthetic.main.include_app_bar.toolbar -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar -import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle - -/** @author Aidan Follestad (@afollestad) */ -class ViewSiteActivity : DarkModeSwitchActivity() { - companion object { - private const val SELECT_CERT_FILE_RQ = 23 - } - - internal val viewModel by viewModel() - private lateinit var validationForm: Form - - private val intentProvider by inject() - private val statusUpdateReceiver by lazy { - StatusUpdateIntentReceiver(application, intentProvider) { - viewModel.setModel(it) - } - } - - @SuppressLint("SetTextI18n") - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_viewsite) - - // Populate view model with initial data - val model = intent.getSerializableExtra(KEY_SITE) as Site - viewModel.setModel(model) - - setupUi() - setupValidation() - lifecycle.run { - addObserver(viewModel) - addObserver(statusUpdateReceiver) - } - - // Loading - loadingProgress.observe(this, viewModel.onIsLoading()) - - // Status - viewModel.status.observe(this, Observer { - iconStatus.setStatus(it) - invalidateMenuForStatus(it) - }) - - // Name - inputName.attachLiveData(this, viewModel.name) - - // Tags - inputTags.attachLiveData(this, viewModel.tags) - - // Url - inputUrl.attachLiveData(this, viewModel.url) - viewModel.onUrlWarningVisibility() - .toViewVisibility(this, textUrlWarning) - - // Timeout - responseTimeoutInput.attachLiveData(this, viewModel.timeout) - - // Validation mode - responseValidationMode.attachLiveData( - lifecycleOwner = this, - data = viewModel.validationMode, - outTransformer = { ValidationMode.fromIndex(it) }, - inTransformer = { it.toIndex() } - ) - viewModel.onValidationModeDescription() - .toViewText(this, validationModeDescription) - - // Validation search term - responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm) - viewModel.onValidationSearchTermVisibility() - .toViewVisibility(this, responseValidationSearchTerm) - - // SSL certificate - sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } - viewModel.certificateUri.distinct() - .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) - - // Headers - headersLayout.attach(viewModel.headers) - - // Last/next check - viewModel.onLastCheckResultText() - .toViewText(this, textLastCheckResult) - viewModel.onNextCheckText() - .toViewText(this, textNextCheck) - } - - private fun setupUi() { - toolbarTitle.text = "" - toolbar.run { - setNavigationIcon(R.drawable.ic_action_close) - setNavigationOnClickListener { finish() } - inflateMenu(R.menu.menu_viewsite) - - menu.findItem(R.id.refresh) - .setActionView(R.layout.menu_item_refresh_icon) - .apply { - actionView.setOnClickListener { viewModel.checkNow() } - } - - setOnMenuItemClickListener { - when (it.itemId) { - R.id.remove -> maybeRemoveSite() - R.id.disableChecks -> maybeDisableChecks() - } - true - } - } - - scrollView.onScroll { - appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) { - appToolbar.dimenFloat(R.dimen.default_elevation) - } else { - 0f - } - } - - val validationOptionsAdapter = ArrayAdapter( - this, - R.layout.list_item_spinner, - resources.getStringArray(R.array.response_validation_options) - ) - validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) - responseValidationMode.adapter = validationOptionsAdapter - - // Disabled button - viewModel.onDisableChecksVisibility() - .observe(this, Observer { - toolbar.menu.findItem(R.id.disableChecks) - .isVisible = it - }) - - // Done item text - viewModel.onDoneButtonText() - .observe(this, Observer { - toolbar.menu.findItem(R.id.commit) - .setTitle(it) - }) - - // SSL certificate - sslCertificateBrowse.setOnClickListener { - val intent = Intent(ACTION_OPEN_DOCUMENT).apply { - addCategory(CATEGORY_OPENABLE) - type = "*/*" - } - startActivityForResult(intent, SELECT_CERT_FILE_RQ) - } - } - - private fun setupValidation() { - validationForm = form { - input(inputName, name = "Name") { - isNotEmpty().description(R.string.please_enter_name) - } - input(inputUrl, name = "URL") { - isNotEmpty().description(R.string.please_enter_url) - isUrl().description(R.string.please_enter_valid_url) - } - input(responseValidationSearchTerm, name = "Search term") { - conditional(responseValidationSearchTerm.isVisibleCondition()) { - isNotEmpty().description(R.string.please_enter_search_term) - } - } - input(responseTimeoutInput, name = "Timeout", optional = true) { - isNumber().greaterThan(0) - .description(R.string.please_enter_networkTimeout) - } - input(sslCertificateInput, name = "Certificate Path", optional = true) { - isUri().hasScheme("file", "content") - .that { it.host != null } - .description(R.string.please_enter_validCertUri) - } - submitWith(toolbar.menu, R.id.commit) { - viewModel.commit { finish() } - } - } - - // Validation script - scriptInputLayout.attach( - codeData = viewModel.validationScript, - visibility = viewModel.onValidationScriptVisibility(), - form = validationForm - ) - - // Check interval - checkIntervalLayout.attach( - valueData = viewModel.checkIntervalValue, - multiplierData = viewModel.checkIntervalUnit, - form = validationForm - ) - - // Retry Policy - retryPolicyLayout.attach( - timesData = viewModel.retryPolicyTimes, - minutesData = viewModel.retryPolicyMinutes, - form = validationForm - ) - } - - override fun onResume() { - super.onResume() - appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { - appToolbar.dimenFloat(R.dimen.default_elevation) - } else { - 0f - } - } - - override fun onActivityResult( - requestCode: Int, - resultCode: Int, - resultData: Intent? - ) { - super.onActivityResult(requestCode, resultCode, resultData) - if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) { - sslCertificateInput.setText(resultData?.data?.toString() ?: "") - } - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - if (intent != null && intent.hasExtra(KEY_SITE)) { - val newModel = intent.getSerializableExtra(KEY_SITE) as Site - viewModel.setModel(newModel) - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt deleted file mode 100644 index ab0a6ea..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.viewsite - -import android.widget.ImageView -import com.afollestad.materialdialogs.MaterialDialog -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.model.Status -import com.afollestad.nocknock.data.model.isPending -import com.afollestad.nocknock.toHtml -import com.afollestad.nocknock.utilities.ext.animateRotation -import kotlinx.android.synthetic.main.include_app_bar.toolbar - -const val KEY_SITE = "site_model" - -internal fun ViewSiteActivity.maybeRemoveSite() { - val model = viewModel.site - MaterialDialog(this).show { - title(R.string.remove_site) - message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml()) - positiveButton(R.string.remove) { - viewModel.removeSite { finish() } - } - negativeButton(android.R.string.cancel) - } -} - -internal fun ViewSiteActivity.maybeDisableChecks() { - val model = viewModel.site - MaterialDialog(this).show { - title(R.string.disable_automatic_checks) - message( - text = context.getString(R.string.disable_automatic_checks_prompt, model.name).toHtml() - ) - positiveButton(R.string.disable) { viewModel.disableSite() } - negativeButton(android.R.string.cancel) - } -} - -internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) { - val refreshIcon = toolbar.menu.findItem(R.id.refresh) - .actionView as ImageView - if (status.isPending()) { - refreshIcon.animateRotation() - } else { - refreshIcon.run { - animate().cancel() - rotation = 0f - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt deleted file mode 100644 index b5c9f93..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.viewsite - -import androidx.annotation.CheckResult -import androidx.annotation.VisibleForTesting -import androidx.annotation.VisibleForTesting.PRIVATE -import androidx.lifecycle.LifecycleObserver -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.AppDatabase -import com.afollestad.nocknock.data.deleteSite -import com.afollestad.nocknock.data.model.Header -import com.afollestad.nocknock.data.model.RetryPolicy -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.Status -import com.afollestad.nocknock.data.model.Status.WAITING -import com.afollestad.nocknock.data.model.ValidationMode -import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.model.ValidationResult -import com.afollestad.nocknock.data.model.textRes -import com.afollestad.nocknock.data.updateSite -import com.afollestad.nocknock.engine.validation.ValidationExecutor -import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.ui.ScopedViewModel -import com.afollestad.nocknock.utilities.ext.formatDate -import com.afollestad.nocknock.utilities.livedata.map -import com.afollestad.nocknock.utilities.livedata.zip -import com.afollestad.nocknock.utilities.providers.StringProvider -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.HttpUrl -import java.lang.System.currentTimeMillis - -/** @author Aidan Follestad (@afollestad) */ -class ViewSiteViewModel( - private val stringProvider: StringProvider, - private val database: AppDatabase, - private val notificationManager: NockNotificationManager, - private val validationManager: ValidationExecutor, - mainDispatcher: CoroutineDispatcher, - private val ioDispatcher: CoroutineDispatcher -) : ScopedViewModel(mainDispatcher), LifecycleObserver { - - lateinit var site: Site - - // Public properties - val status = MutableLiveData() - val name = MutableLiveData() - val tags = MutableLiveData() - val url = MutableLiveData() - val timeout = MutableLiveData() - val validationMode = MutableLiveData() - val validationSearchTerm = MutableLiveData() - val validationScript = MutableLiveData() - val checkIntervalValue = MutableLiveData() - val checkIntervalUnit = MutableLiveData() - val retryPolicyTimes = MutableLiveData() - val retryPolicyMinutes = MutableLiveData() - val headers = MutableLiveData>() - val certificateUri = MutableLiveData() - internal val disabled = MutableLiveData() - internal val lastResult = MutableLiveData() - - private val isLoading = MutableLiveData() - - @CheckResult fun onIsLoading(): LiveData = isLoading - - @CheckResult fun onUrlWarningVisibility(): LiveData { - return url.map { - val parsed = HttpUrl.parse(it) - return@map it.isNotEmpty() && parsed == null - } - } - - @CheckResult fun onValidationModeDescription(): LiveData { - return validationMode.map { - when (it!!) { - STATUS_CODE -> R.string.validation_mode_status_desc - TERM_SEARCH -> R.string.validation_mode_term_desc - JAVASCRIPT -> R.string.validation_mode_javascript_desc - } - } - } - - @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } - - @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } - - @CheckResult fun onDisableChecksVisibility(): LiveData = disabled.map { !it } - - @CheckResult fun onDoneButtonText(): LiveData = - disabled.map { - if (it) R.string.renable_and_save_changes - else R.string.save_changes - } - - @CheckResult fun onLastCheckResultText(): LiveData = lastResult.map { - if (it == null) { - stringProvider.get(R.string.none) - } else { - val statusText = it.status.textRes() - if (statusText == 0) { - it.reason - } else { - stringProvider.get(statusText) - } - } - } - - @CheckResult fun onNextCheckText(): LiveData { - return zip(disabled, lastResult) - .map { - val disabled = it.first - val lastResult = it.second - if (disabled) { - stringProvider.get(R.string.auto_checks_disabled) - } else { - val lastCheck = lastResult?.timestampMs ?: currentTimeMillis() - (lastCheck + getCheckIntervalMs()).formatDate() - } - } - } - - // Actions - fun commit(done: () -> Unit) { - scope.launch { - val updatedModel = getUpdatedDbModel() ?: return@launch - isLoading.value = true - - withContext(ioDispatcher) { - database.updateSite(updatedModel) - } - validationManager.scheduleValidation( - site = updatedModel, - rightNow = true, - cancelPrevious = true - ) - - isLoading.value = false - done() - } - } - - fun checkNow() { - val checkModel = site.withStatus( - status = WAITING - ) - setModel(checkModel) - validationManager.scheduleValidation( - site = checkModel, - rightNow = true, - cancelPrevious = true - ) - } - - fun removeSite(done: () -> Unit) { - validationManager.cancelScheduledValidation(site) - notificationManager.cancelStatusNotification(site) - - scope.launch { - isLoading.value = true - withContext(ioDispatcher) { - database.deleteSite(site) - } - isLoading.value = false - done() - } - } - - fun disableSite() { - validationManager.cancelScheduledValidation(site) - notificationManager.cancelStatusNotification(site) - - scope.launch { - isLoading.value = true - val newModel = site.copy( - settings = site.settings!!.copy( - disabled = true - ) - ) - withContext(ioDispatcher) { - database.updateSite(newModel) - } - isLoading.value = false - setModel(newModel) - } - } - - // Utilities - @VisibleForTesting(otherwise = PRIVATE) - fun getCheckIntervalMs(): Long { - val value = checkIntervalValue.value ?: return 0 - val unit = checkIntervalUnit.value ?: return 0 - return value * unit - } - - @VisibleForTesting(otherwise = PRIVATE) - fun getValidationArgs(): String? { - return when (validationMode.value) { - TERM_SEARCH -> validationSearchTerm.value?.trim() - JAVASCRIPT -> validationScript.value?.trim() - else -> null - } - } - - private fun getUpdatedDbModel(): Site? { - val timeout = timeout.value ?: 10_000 - val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: "" - - val newSettings = site.settings!!.copy( - validationIntervalMs = getCheckIntervalMs(), - validationMode = validationMode.value!!, - validationArgs = getValidationArgs(), - networkTimeout = timeout, - disabled = false, - certificate = certificateUri.value?.toString() - ) - - val retryPolicyTimes = retryPolicyTimes.value ?: 0 - val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 - val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { - if (site.retryPolicy != null) { - // Have existing policy, update it - site.retryPolicy!!.copy( - count = retryPolicyTimes, - minutes = retryPolicyMinutes - ) - } else { - // Create new policy - RetryPolicy( - count = retryPolicyTimes, - minutes = retryPolicyMinutes - ) - } - } else { - // No policy - null - } - - return site.copy( - name = name.value!!.trim(), - tags = cleanedTags, - url = url.value!!.trim(), - settings = newSettings, - retryPolicy = retryPolicy, - headers = headers.value ?: emptyList() - ) - .withStatus(status = WAITING) - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt deleted file mode 100644 index 800f235..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Designed and developed by Aidan Follestad (@afollestad) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.afollestad.nocknock.ui.viewsite - -import com.afollestad.nocknock.data.model.RetryPolicy -import com.afollestad.nocknock.data.model.Site -import com.afollestad.nocknock.data.model.Status.WAITING -import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.utilities.ext.DAY -import com.afollestad.nocknock.utilities.ext.HOUR -import com.afollestad.nocknock.utilities.ext.MINUTE -import com.afollestad.nocknock.utilities.ext.WEEK -import kotlin.math.ceil - -fun ViewSiteViewModel.setModel(site: Site) { - val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!") - this.site = site - - status.value = site.lastResult?.status ?: WAITING - name.value = site.name - tags.value = site.tags - url.value = site.url - timeout.value = settings.networkTimeout - - validationMode.value = settings.validationMode - when (settings.validationMode) { - TERM_SEARCH -> { - validationSearchTerm.value = settings.validationArgs - validationScript.value = null - } - JAVASCRIPT -> { - validationSearchTerm.value = null - validationScript.value = settings.validationArgs - } - else -> { - validationSearchTerm.value = null - validationScript.value = null - } - } - - setCheckInterval(settings.validationIntervalMs) - setRetryPolicy(site.retryPolicy) - headers.value = site.headers - if (settings.certificate == "null") { - certificateUri.value = "" - } else { - certificateUri.value = settings.certificate - } - - this.disabled.value = settings.disabled - this.lastResult.value = site.lastResult -} - -private fun ViewSiteViewModel.setCheckInterval(interval: Long) { - when { - interval >= WEEK -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, WEEK) - checkIntervalUnit.value = WEEK - } - interval >= DAY -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, DAY) - checkIntervalUnit.value = DAY - } - interval >= HOUR -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, HOUR) - checkIntervalUnit.value = HOUR - } - interval >= MINUTE -> { - checkIntervalValue.value = - getIntervalFromUnit(interval, MINUTE) - checkIntervalUnit.value = MINUTE - } - else -> { - checkIntervalValue.value = 0 - checkIntervalUnit.value = MINUTE - } - } -} - -private fun ViewSiteViewModel.setRetryPolicy(policy: RetryPolicy?) { - if (policy == null) return - retryPolicyTimes.value = policy.count - retryPolicyMinutes.value = policy.minutes -} - -private fun getIntervalFromUnit( - millis: Long, - unit: Long -): Int { - val intervalFloat = millis.toFloat() - val byFloat = unit.toFloat() - return ceil(intervalFloat / byFloat).toInt() -} diff --git a/app/src/main/java/com/afollestad/nocknock/util/AlarmUtil.java b/app/src/main/java/com/afollestad/nocknock/util/AlarmUtil.java new file mode 100644 index 0000000..7effdb4 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/util/AlarmUtil.java @@ -0,0 +1,59 @@ +package com.afollestad.nocknock.util; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.afollestad.nocknock.api.ServerModel; +import com.afollestad.nocknock.services.CheckService; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * @author Aidan Follestad (afollestad) + */ +public class AlarmUtil { + + private final static int BASE_RQC = 69; + + public static PendingIntent getSiteIntent(Context context, ServerModel site) { + return PendingIntent.getService(context, + BASE_RQC + (int) site.id, + new Intent(context, CheckService.class) + .putExtra(CheckService.MODEL_ID, site.id), + PendingIntent.FLAG_UPDATE_CURRENT); + } + + private static AlarmManager am(Context context) { + return (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + } + + public static void cancelSiteChecks(Context context, ServerModel site) { + PendingIntent pi = getSiteIntent(context, site); + am(context).cancel(pi); + } + + public static void setSiteChecks(Context context, ServerModel site) { + cancelSiteChecks(context, site); + if (site.checkInterval <= 0) return; + if (site.lastCheck <= 0) + site.lastCheck = System.currentTimeMillis(); + final long nextCheck = site.lastCheck + site.checkInterval; + final AlarmManager aMgr = am(context); + final PendingIntent serviceIntent = getSiteIntent(context, site); + aMgr.setRepeating(AlarmManager.RTC_WAKEUP, nextCheck, site.checkInterval, serviceIntent); + final SimpleDateFormat df = new SimpleDateFormat("EEE MMM dd hh:mm:ssa z yyyy", Locale.getDefault()); + Log.d("AlarmUtil", String.format(Locale.getDefault(), "Set site check alarm for %s (%s), check interval: %d, next check: %s", + site.name, site.url, site.checkInterval, df.format(new Date(nextCheck)))); + } + + public static void setSiteChecks(Context context, ServerModel[] sites) { + if (sites == null || sites.length == 0) return; + for (ServerModel site : sites) + setSiteChecks(context, site); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/util/JsUtil.java b/app/src/main/java/com/afollestad/nocknock/util/JsUtil.java new file mode 100644 index 0000000..160927b --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/util/JsUtil.java @@ -0,0 +1,72 @@ +package com.afollestad.nocknock.util; + +import android.util.Log; + +import org.mozilla.javascript.Context; +import org.mozilla.javascript.EvaluatorException; +import org.mozilla.javascript.Function; +import org.mozilla.javascript.Scriptable; + +/** + * @author Aidan Follestad (afollestad) + */ +public class JsUtil { + + public static String exec(String code, String response) { + try { + final String func = String.format( + "function validate(response) { " + + "try { " + + "%s " + + "} catch(e) { " + + "return e; " + + "} " + + "}", code.replace("\n", " ")); + + // Every Rhino VM begins with the enter() + // This Context is not Android's Context + Context rhino = Context.enter(); + + // Turn off optimization to make Rhino Android compatible + rhino.setOptimizationLevel(-1); + try { + Scriptable scope = rhino.initStandardObjects(); + + // Note the forth argument is 1, which means the JavaScript source has + // been compressed to only one line using something like YUI + rhino.evaluateString(scope, func, "JavaScript", 1, null); + + // Get the functionName defined in JavaScriptCode + Function jsFunction = (Function) scope.get("validate", scope); + + // Call the function with params + Object jsResult = jsFunction.call(rhino, scope, scope, new Object[]{response}); + + // Parse the jsResult object to a String + String result = Context.toString(jsResult); + + boolean success = result != null && result.equals("true"); + String message = "The script returned a value other than true!"; + if (!success && result != null && !result.equals("false")) { + if (result.equals("undefined")) { + message = "The script did not return or throw anything!"; + } else { + message = result; + } + } + + Log.d("JsUtil", "Evaluated to " + message + " (" + success + "): " + code); + return !success ? message : null; + } finally { + Context.exit(); + } + } catch (EvaluatorException e) { + return e.getMessage(); + + + } + } + + private JsUtil() { + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java b/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java new file mode 100644 index 0000000..6cb885b --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java @@ -0,0 +1,32 @@ +package com.afollestad.nocknock.util; + +import android.graphics.Path; +import android.support.design.widget.FloatingActionButton; +import android.view.View; + +/** + * @author Aidan Follestad (afollestad) + */ +public final class MathUtil { + + public static Path bezierCurve(FloatingActionButton fab, View rootView) { + final int fabCenterX = (int) (fab.getX() + fab.getMeasuredWidth() / 2); + final int fabCenterY = (int) (fab.getY() + fab.getMeasuredHeight() / 2); + + final int endCenterX = (rootView.getMeasuredWidth() / 2) - (fab.getMeasuredWidth() / 2); + final int endCenterY = (rootView.getMeasuredHeight() / 2) - (fab.getMeasuredHeight() / 2); + + final int halfX = (fabCenterX - endCenterX) / 2; + final int halfY = (fabCenterY - endCenterY) / 2; + int mControlX = endCenterX + halfX; + int mControlY = endCenterY + halfY; + mControlY -= halfY; + mControlX += halfX; + + Path path = new Path(); + path.moveTo(fab.getX(), fab.getY()); + path.quadTo(mControlX, mControlY, endCenterX, endCenterY); + + return path; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/util/NetworkUtil.java b/app/src/main/java/com/afollestad/nocknock/util/NetworkUtil.java new file mode 100644 index 0000000..3680f26 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/util/NetworkUtil.java @@ -0,0 +1,18 @@ +package com.afollestad.nocknock.util; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +/** + * @author Aidan Follestad (afollestad) + */ +public class NetworkUtil { + + public static boolean hasInternet(Context context) { + final ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + final NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + return activeNetwork != null && + activeNetwork.isConnectedOrConnecting(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/util/TimeUtil.java b/app/src/main/java/com/afollestad/nocknock/util/TimeUtil.java new file mode 100644 index 0000000..3a65b73 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/util/TimeUtil.java @@ -0,0 +1,32 @@ +package com.afollestad.nocknock.util; + +/** + * @author Aidan Follestad (afollestad) + */ +public class TimeUtil { + + public final static long SECOND = 1000; + public final static long MINUTE = SECOND * 60; + public final static long HOUR = MINUTE * 60; + public final static long DAY = HOUR * 24; + public final static long WEEK = DAY * 7; + public final static long MONTH = WEEK * 4; + + public static String str(long duration) { + if (duration <= 0) { + return ""; + } else if (duration >= MONTH) { + return (int) Math.ceil(((float) duration / (float) MONTH)) + "mo"; + } else if (duration >= WEEK) { + return (int) Math.ceil(((float) duration / (float) WEEK)) + "w"; + } else if (duration >= DAY) { + return (int) Math.ceil(((float) duration / (float) DAY)) + "d"; + } else if (duration >= HOUR) { + return (int) Math.ceil(((float) duration / (float) HOUR)) + "h"; + } else if (duration >= MINUTE) { + return (int) Math.ceil(((float) duration / (float) MINUTE)) + "m"; + } else { + return "<1m"; + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/views/DividerItemDecoration.java b/app/src/main/java/com/afollestad/nocknock/views/DividerItemDecoration.java new file mode 100644 index 0000000..cc4396a --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/views/DividerItemDecoration.java @@ -0,0 +1,104 @@ +package com.afollestad.nocknock.views; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.View; + +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +public class DividerItemDecoration extends RecyclerView.ItemDecoration { + + private static final int[] ATTRS = new int[]{ + android.R.attr.listDivider + }; + + public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL; + + public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL; + + private Drawable mDivider; + + private int mOrientation; + + public DividerItemDecoration(Context context, int orientation) { + final TypedArray a = context.obtainStyledAttributes(ATTRS); + mDivider = a.getDrawable(0); + a.recycle(); + setOrientation(orientation); + } + + public void setOrientation(int orientation) { + if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) { + throw new IllegalArgumentException("invalid orientation"); + } + mOrientation = orientation; + } + + @Override + public void onDraw(Canvas c, RecyclerView parent) { + if (mOrientation == VERTICAL_LIST) { + drawVertical(c, parent); + } else { + drawHorizontal(c, parent); + } + } + + public void drawVertical(Canvas c, RecyclerView parent) { + final int left = parent.getPaddingLeft(); + final int right = parent.getWidth() - parent.getPaddingRight(); + + final int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = parent.getChildAt(i); + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child + .getLayoutParams(); + final int top = child.getBottom() + params.bottomMargin; + final int bottom = top + mDivider.getIntrinsicHeight(); + mDivider.setBounds(left, top, right, bottom); + mDivider.draw(c); + } + } + + public void drawHorizontal(Canvas c, RecyclerView parent) { + final int top = parent.getPaddingTop(); + final int bottom = parent.getHeight() - parent.getPaddingBottom(); + + final int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + final View child = parent.getChildAt(i); + final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child + .getLayoutParams(); + final int left = child.getRight() + params.rightMargin; + final int right = left + mDivider.getIntrinsicHeight(); + mDivider.setBounds(left, top, right, bottom); + mDivider.draw(c); + } + } + + @Override + public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) { + if (mOrientation == VERTICAL_LIST) { + outRect.set(0, 0, 0, mDivider.getIntrinsicHeight()); + } else { + outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java b/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java new file mode 100644 index 0000000..ac8cf13 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java @@ -0,0 +1,47 @@ +package com.afollestad.nocknock.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; + +import com.afollestad.nocknock.R; +import com.afollestad.nocknock.api.ServerStatus; + +/** + * @author Aidan Follestad (afollestad) + */ +public class StatusImageView extends ImageView { + + public StatusImageView(Context context) { + super(context); + setStatus(ServerStatus.OK); + } + + public StatusImageView(Context context, AttributeSet attrs) { + super(context, attrs); + setStatus(ServerStatus.OK); + } + + public StatusImageView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setStatus(ServerStatus.OK); + } + + public void setStatus(@ServerStatus.Enum int status) { + switch (status) { + case ServerStatus.CHECKING: + case ServerStatus.WAITING: + setImageResource(R.drawable.status_progress); + setBackgroundResource(R.drawable.yellow_circle); + break; + case ServerStatus.ERROR: + setImageResource(R.drawable.status_error); + setBackgroundResource(R.drawable.red_circle); + break; + case ServerStatus.OK: + setImageResource(R.drawable.status_ok); + setBackgroundResource(R.drawable.green_circle); + break; + } + } +} diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml new file mode 100644 index 0000000..900d3cc --- /dev/null +++ b/app/src/main/res/anim/fade_out.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/unchecked_chip_text.xml b/app/src/main/res/color/unchecked_chip_text.xml deleted file mode 100644 index 8e7f4df..0000000 --- a/app/src/main/res/color/unchecked_chip_text.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable-hdpi-v11/ic_notification.png b/app/src/main/res/drawable-hdpi-v11/ic_notification.png new file mode 100644 index 0000000..5c51b0b Binary files /dev/null and b/app/src/main/res/drawable-hdpi-v11/ic_notification.png differ diff --git a/app/src/main/res/drawable-mdpi-v11/ic_notification.png b/app/src/main/res/drawable-mdpi-v11/ic_notification.png new file mode 100644 index 0000000..610685d Binary files /dev/null and b/app/src/main/res/drawable-mdpi-v11/ic_notification.png differ diff --git a/app/src/main/res/drawable-xhdpi-v11/ic_notification.png b/app/src/main/res/drawable-xhdpi-v11/ic_notification.png new file mode 100644 index 0000000..88e634f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi-v11/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi-v11/ic_notification.png b/app/src/main/res/drawable-xxhdpi-v11/ic_notification.png new file mode 100644 index 0000000..dc4957d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi-v11/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png b/app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png new file mode 100644 index 0000000..af4779b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png differ diff --git a/app/src/main/res/drawable/checked_chip.xml b/app/src/main/res/drawable/checked_chip.xml deleted file mode 100644 index 85010f5..0000000 --- a/app/src/main/res/drawable/checked_chip.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/checked_chip_pressed.xml b/app/src/main/res/drawable/checked_chip_pressed.xml deleted file mode 100644 index 0d7c176..0000000 --- a/app/src/main/res/drawable/checked_chip_pressed.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/checked_chip_selector.xml b/app/src/main/res/drawable/checked_chip_selector.xml deleted file mode 100644 index fa9df00..0000000 --- a/app/src/main/res/drawable/checked_chip_selector.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml index 7561a3c..98beb0c 100644 --- a/app/src/main/res/drawable/divider.xml +++ b/app/src/main/res/drawable/divider.xml @@ -1,4 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/green_circle.xml b/app/src/main/res/drawable/green_circle.xml new file mode 100644 index 0000000..31bc563 --- /dev/null +++ b/app/src/main/res/drawable/green_circle.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_action_close.xml b/app/src/main/res/drawable/ic_action_close.xml index 241f9be..f4977c9 100644 --- a/app/src/main/res/drawable/ic_action_close.xml +++ b/app/src/main/res/drawable/ic_action_close.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportHeight="24.0" android:viewportWidth="24.0"> - + diff --git a/app/src/main/res/drawable/ic_action_delete.xml b/app/src/main/res/drawable/ic_action_delete.xml index 901c3e1..d85947c 100644 --- a/app/src/main/res/drawable/ic_action_delete.xml +++ b/app/src/main/res/drawable/ic_action_delete.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportHeight="24.0" android:viewportWidth="24.0"> - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_action_refresh.xml b/app/src/main/res/drawable/ic_action_refresh.xml index 5175bda..85eb103 100644 --- a/app/src/main/res/drawable/ic_action_refresh.xml +++ b/app/src/main/res/drawable/ic_action_refresh.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportHeight="24.0" android:viewportWidth="24.0"> - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml index c0ca276..7b18128 100644 --- a/app/src/main/res/drawable/ic_add.xml +++ b/app/src/main/res/drawable/ic_add.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportHeight="24.0" android:viewportWidth="24.0"> - + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml deleted file mode 100644 index 00fc15d..0000000 --- a/app/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_down_arrow.xml b/app/src/main/res/drawable/ic_down_arrow.xml new file mode 100644 index 0000000..2341755 --- /dev/null +++ b/app/src/main/res/drawable/ic_down_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/red_circle.xml b/app/src/main/res/drawable/red_circle.xml new file mode 100644 index 0000000..d6bdd26 --- /dev/null +++ b/app/src/main/res/drawable/red_circle.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/status_error.xml b/app/src/main/res/drawable/status_error.xml new file mode 100644 index 0000000..6f67037 --- /dev/null +++ b/app/src/main/res/drawable/status_error.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/status_ok.xml b/app/src/main/res/drawable/status_ok.xml new file mode 100644 index 0000000..3ec2bc9 --- /dev/null +++ b/app/src/main/res/drawable/status_ok.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/status_progress.xml b/app/src/main/res/drawable/status_progress.xml new file mode 100644 index 0000000..c100e60 --- /dev/null +++ b/app/src/main/res/drawable/status_progress.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/unchecked_chip.xml b/app/src/main/res/drawable/unchecked_chip.xml deleted file mode 100644 index 1864bc5..0000000 --- a/app/src/main/res/drawable/unchecked_chip.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/unchecked_chip_pressed.xml b/app/src/main/res/drawable/unchecked_chip_pressed.xml deleted file mode 100644 index c387d70..0000000 --- a/app/src/main/res/drawable/unchecked_chip_pressed.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/unchecked_chip_selector.xml b/app/src/main/res/drawable/unchecked_chip_selector.xml deleted file mode 100644 index ba01f74..0000000 --- a/app/src/main/res/drawable/unchecked_chip_selector.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/yellow_circle.xml b/app/src/main/res/drawable/yellow_circle.xml new file mode 100644 index 0000000..1201e65 --- /dev/null +++ b/app/src/main/res/drawable/yellow_circle.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml index 80f83da..4bf5e29 100644 --- a/app/src/main/res/layout/activity_addsite.xml +++ b/app/src/main/res/layout/activity_addsite.xml @@ -1,222 +1,242 @@ - + android:background="?colorPrimary" + android:orientation="vertical"> - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent"> + android:orientation="vertical" + android:paddingBottom="@dimen/content_inset" + android:paddingLeft="@dimen/content_inset" + android:paddingRight="@dimen/content_inset"> - + -