diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 0000000..2d7d09f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Something is crashing or not working as intended + +--- + +*Please consider making a Pull Request if you are capable of doing so.* + +**App Version:** + +x.x.x + +**Affected Device(s):** + +Google Pixel 3 XL with Android 9.0 + +**Describe the Bug:** + +A clear description of what is the bug is. + +**To Reproduce:** +1. +2. +3. + +**Expected Behavior:** + +A clear description of what you expected to happen. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md new file mode 100644 index 0000000..77310ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +*Please consider making a Pull Request if you are capable of doing so.* + +**Description what you'd like to happen:** + +A clear description if the feature or behavior you'd like implemented. + +**Describe alternatives you've considered:** + +A clear description of any alternative solutions you've considered. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6307e10 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +### 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 161128f..454e51a 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,6 @@ gradle-app.setting .gradletasknamecache # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties \ No newline at end of file +# gradle/wrapper/gradle-wrapper.properties + +app/google-services.json \ No newline at end of file diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index f519041..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -nock-nock \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 96cc43e..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml deleted file mode 100644 index e7bedf3..0000000 --- a/.idea/copyright/profiles_settings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index 97626ba..0000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index fbb6828..50f0406 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,46 +1,49 @@ - - + + + + - - - - - - - - - - - + - \ No newline at end of file + diff --git a/.idea/modules.xml b/.idea/modules.xml index 56aca0f..23f5d23 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -3,7 +3,12 @@ + + + + + - \ No newline at end of file + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 70085e5..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index c4d3367..0000000 Binary files a/NockNock-0.1.3.0.apk and /dev/null differ diff --git a/README.md b/README.md index a15f38e..84ee16c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ ## Nock Nock -[![Build Status](https://travis-ci.org/afollestad/nock-nock.svg)](https://travis-ci.org/afollestad/nock-nock) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html) -![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcasemain.png) +![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png) Nock Nock is a simple app which allows you to monitor your websites for maximum uptime. @@ -11,4 +10,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 \ No newline at end of file +Get it on Google Play diff --git a/app/build.gradle b/app/build.gradle index 23e53a1..d6b315e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,43 +1,79 @@ +apply from: '../dependencies.gradle' apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-android-extensions' android { - compileSdkVersion 24 - buildToolsVersion "24.0.1" + compileSdkVersion versions.compileSdk + buildToolsVersion versions.buildTools - defaultConfig { - applicationId "com.afollestad.nocknock" - minSdkVersion 21 - targetSdkVersion 24 - versionCode 13 - versionName "0.1.3.0" + defaultConfig { + applicationId "com.afollestad.nocknock" + minSdkVersion versions.minSdk + targetSdkVersion versions.compileSdk + versionCode versions.publishVersionCode + versionName versions.publishVersion + } - lintOptions { - abortOnError false - } - jackOptions { - enabled true - } - } + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } + packagingOptions { + exclude 'META-INF/atomicfu.kotlin_module' + } } dependencies { - 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 + 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 diff --git a/app/libs/rhino-1.7.7.1.jar b/app/libs/rhino-1.7.7.1.jar deleted file mode 100644 index a8b9417..0000000 Binary files a/app/libs/rhino-1.7.7.1.jar and /dev/null differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index 666743b..0000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,17 +0,0 @@ -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in C:\Users\drumm\AppData\Local\Android\sdk/tools/proguard/proguard-android.txt -# You can edit the include path and order by changing the proguardFiles -# directive in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d6a9d32..332578e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,60 +3,57 @@ 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 new file mode 100644 index 0000000..828e01f --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/AppExt.kt @@ -0,0 +1,92 @@ +/** + * 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 new file mode 100644 index 0000000..3c29301 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt @@ -0,0 +1,79 @@ +/** + * 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 deleted file mode 100644 index b4da56b..0000000 --- a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.java +++ /dev/null @@ -1,171 +0,0 @@ -package com.afollestad.nocknock.adapter; - -import android.support.v7.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.afollestad.nocknock.R; -import com.afollestad.nocknock.api.ServerModel; -import com.afollestad.nocknock.api.ServerStatus; -import com.afollestad.nocknock.util.TimeUtil; -import com.afollestad.nocknock.views.StatusImageView; - -import java.util.ArrayList; -import java.util.Collections; - -/** - * @author Aidan Follestad (afollestad) - */ -public class ServerAdapter extends RecyclerView.Adapter { - - 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 new file mode 100644 index 0000000..e1b034c --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt @@ -0,0 +1,131 @@ +/** + * 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 new file mode 100644 index 0000000..de8b7ca --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt @@ -0,0 +1,40 @@ +/** + * 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 new file mode 100644 index 0000000..7bf22e5 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt @@ -0,0 +1,115 @@ +/** + * 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 deleted file mode 100644 index 1e3b6df..0000000 --- a/app/src/main/java/com/afollestad/nocknock/api/ServerModel.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index f7f98fe..0000000 --- a/app/src/main/java/com/afollestad/nocknock/api/ServerStatus.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.afollestad.nocknock.api; - -import android.support.annotation.IntDef; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -/** - * @author Aidan Follestad (afollestad) - */ -public final class ServerStatus { - - public 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 deleted file mode 100644 index 2606635..0000000 --- a/app/src/main/java/com/afollestad/nocknock/api/ValidationMode.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.afollestad.nocknock.api; - -import android.support.annotation.IntDef; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -/** - * @author Aidan Follestad (afollestad) - */ -public final class ValidationMode { - - public 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 new file mode 100644 index 0000000..c5567c5 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt @@ -0,0 +1,68 @@ +/** + * 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 deleted file mode 100644 index 3eeed80..0000000 --- a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.afollestad.nocknock.dialogs; - -import android.app.Dialog; -import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.v4.app.DialogFragment; -import android.support.v7.app.AppCompatActivity; -import android.text.Html; - -import com.afollestad.materialdialogs.MaterialDialog; -import com.afollestad.nocknock.R; - -/** - * @author Aidan Follestad (afollestad) - */ -public class AboutDialog extends DialogFragment { - - public static void show(AppCompatActivity context) { - AboutDialog dialog = new AboutDialog(); - dialog.show(context.getSupportFragmentManager(), "[ABOUT_DIALOG]"); - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - return new MaterialDialog.Builder(getActivity()) - .title(R.string.about) - .positiveText(R.string.dismiss) - .content(Html.fromHtml(getString(R.string.about_body))) - .contentLineSpacing(1.6f) - .build(); - } -} \ 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 new file mode 100644 index 0000000..f0c152f --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt @@ -0,0 +1,44 @@ +/** + * 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 new file mode 100644 index 0000000..07f6410 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt @@ -0,0 +1,72 @@ +/** + * 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 new file mode 100644 index 0000000..654b76d --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt @@ -0,0 +1,32 @@ +/** + * 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 new file mode 100644 index 0000000..ab8cd79 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt @@ -0,0 +1,58 @@ +/** + * 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 new file mode 100644 index 0000000..10b97fb --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt @@ -0,0 +1,37 @@ +/** + * 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 deleted file mode 100644 index 4f5666f..0000000 --- a/app/src/main/java/com/afollestad/nocknock/receivers/BootReceiver.java +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 7ceed62..0000000 --- a/app/src/main/java/com/afollestad/nocknock/receivers/ConnectivityReceiver.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.afollestad.nocknock.receivers; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.util.Log; - -import com.afollestad.nocknock.services.CheckService; -import com.afollestad.nocknock.util.NetworkUtil; - -/** - * @author Aidan Follestad (afollestad) - */ -public class ConnectivityReceiver extends BroadcastReceiver { - - @Override - public void onReceive(Context context, Intent intent) { - final boolean hasInternet = NetworkUtil.hasInternet(context); - Log.v("ConnectivityReceiver", "Connectivity state changed... has internet? " + hasInternet); - if (hasInternet) { - context.startService(new Intent(context, CheckService.class) - .putExtra(CheckService.ONLY_WAITING, true)); - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/services/CheckService.java b/app/src/main/java/com/afollestad/nocknock/services/CheckService.java deleted file mode 100644 index 2b9ab94..0000000 --- a/app/src/main/java/com/afollestad/nocknock/services/CheckService.java +++ /dev/null @@ -1,241 +0,0 @@ -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 deleted file mode 100644 index cb54ece..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/AddSiteActivity.java +++ /dev/null @@ -1,255 +0,0 @@ -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 new file mode 100644 index 0000000..3220567 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt @@ -0,0 +1,82 @@ +/** + * 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 deleted file mode 100644 index 88f8ba8..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/MainActivity.java +++ /dev/null @@ -1,326 +0,0 @@ -package com.afollestad.nocknock.ui; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ObjectAnimator; -import android.annotation.SuppressLint; -import android.app.ActivityOptions; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.graphics.Path; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.support.design.widget.FloatingActionButton; -import android.support.v4.app.NotificationManagerCompat; -import android.support.v4.content.ContextCompat; -import android.support.v4.widget.SwipeRefreshLayout; -import android.support.v7.app.AppCompatActivity; -import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.RecyclerView; -import android.text.Html; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.animation.PathInterpolator; -import android.widget.TextView; - -import com.afollestad.bridge.Bridge; -import com.afollestad.inquiry.Inquiry; -import com.afollestad.materialdialogs.MaterialDialog; -import com.afollestad.nocknock.R; -import com.afollestad.nocknock.adapter.ServerAdapter; -import com.afollestad.nocknock.api.ServerModel; -import com.afollestad.nocknock.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 new file mode 100644 index 0000000..2930fea --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt @@ -0,0 +1,26 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui + +/** @author Aidan Follestad (@afollestad) */ +enum class NightMode { + /** Night mode is on at the system level. */ + ENABLED, + /** Night mode is off at the system level. */ + DISABLED, + /** We don't know about night mode, fallback to custom impl. */ + UNKNOWN +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt new file mode 100644 index 0000000..750173a --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt @@ -0,0 +1,36 @@ +/** + * 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 deleted file mode 100644 index 5edbef3..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/ViewSiteActivity.java +++ /dev/null @@ -1,340 +0,0 @@ -package com.afollestad.nocknock.ui; - -import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.net.Uri; -import android.os.Bundle; -import android.support.annotation.Nullable; -import android.support.v7.app.AppCompatActivity; -import android.support.v7.widget.Toolbar; -import android.util.Log; -import android.util.Patterns; -import android.view.MenuItem; -import android.view.View; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; -import android.widget.EditText; -import android.widget.Spinner; -import android.widget.TextView; - -import com.afollestad.bridge.Bridge; -import com.afollestad.inquiry.Inquiry; -import com.afollestad.nocknock.R; -import com.afollestad.nocknock.api.ServerModel; -import com.afollestad.nocknock.api.ServerStatus; -import com.afollestad.nocknock.api.ValidationMode; -import com.afollestad.nocknock.services.CheckService; -import com.afollestad.nocknock.util.TimeUtil; -import com.afollestad.nocknock.views.StatusImageView; - -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -/** - * @author Aidan Follestad (afollestad) - */ -public class ViewSiteActivity extends AppCompatActivity implements View.OnClickListener, Toolbar.OnMenuItemClickListener { - - private StatusImageView iconStatus; - private EditText inputName; - private EditText inputUrl; - private EditText inputCheckInterval; - private Spinner checkIntervalSpinner; - private TextView textLastCheckResult; - private TextView textNextCheck; - private TextView textUrlWarning; - private Spinner responseValidationSpinner; - - private ServerModel mModel; - - private final BroadcastReceiver mReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - Log.v("ViewSiteActivity", "Received " + intent.getAction()); - final ServerModel model = (ServerModel) intent.getSerializableExtra("model"); - if (model != null) { - mModel = model; - update(); - } - } - }; - - @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 new file mode 100644 index 0000000..e15a29f --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt @@ -0,0 +1,235 @@ +/** + * 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 new file mode 100644 index 0000000..d7d8ed5 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt @@ -0,0 +1,187 @@ +/** + * 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 new file mode 100644 index 0000000..c524555 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt @@ -0,0 +1,99 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui.addsite + +import com.afollestad.nocknock.data.model.RetryPolicy +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.utilities.ext.DAY +import com.afollestad.nocknock.utilities.ext.HOUR +import com.afollestad.nocknock.utilities.ext.MINUTE +import com.afollestad.nocknock.utilities.ext.WEEK +import kotlin.math.ceil + +fun AddSiteViewModel.prePopulateFromModel(site: Site) { + val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!") + + name.value = site.name + tags.value = site.tags + url.value = site.url + timeout.value = settings.networkTimeout + + validationMode.value = settings.validationMode + when (settings.validationMode) { + TERM_SEARCH -> { + validationSearchTerm.value = settings.validationArgs + validationScript.value = null + } + JAVASCRIPT -> { + validationSearchTerm.value = null + validationScript.value = settings.validationArgs + } + else -> { + validationSearchTerm.value = null + validationScript.value = null + } + } + + setCheckInterval(settings.validationIntervalMs) + setRetryPolicy(site.retryPolicy) + headers.value = site.headers +} + +private fun AddSiteViewModel.setCheckInterval(interval: Long) { + when { + interval >= WEEK -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, WEEK) + checkIntervalUnit.value = WEEK + } + interval >= DAY -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, DAY) + checkIntervalUnit.value = DAY + } + interval >= HOUR -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, HOUR) + checkIntervalUnit.value = HOUR + } + interval >= MINUTE -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, MINUTE) + checkIntervalUnit.value = MINUTE + } + else -> { + checkIntervalValue.value = 0 + checkIntervalUnit.value = MINUTE + } + } +} + +private fun AddSiteViewModel.setRetryPolicy(policy: RetryPolicy?) { + if (policy == null) return + retryPolicyTimes.value = policy.count + retryPolicyMinutes.value = policy.minutes +} + +private fun getIntervalFromUnit( + millis: Long, + unit: Long +): Int { + val intervalFloat = millis.toFloat() + val byFloat = unit.toFloat() + return ceil(intervalFloat / byFloat).toInt() +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt new file mode 100644 index 0000000..aec76d8 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt @@ -0,0 +1,149 @@ +/** + * 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 new file mode 100644 index 0000000..e11ca08 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt @@ -0,0 +1,73 @@ +/** + * 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 new file mode 100644 index 0000000..bad64fc --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt @@ -0,0 +1,157 @@ +/** + * 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 new file mode 100644 index 0000000..2aa312c --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt @@ -0,0 +1,291 @@ +/** + * 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 new file mode 100644 index 0000000..ab0a6ea --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt @@ -0,0 +1,64 @@ +/** + * 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 new file mode 100644 index 0000000..b5c9f93 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt @@ -0,0 +1,268 @@ +/** + * 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 new file mode 100644 index 0000000..800f235 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt @@ -0,0 +1,110 @@ +/** + * 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 deleted file mode 100644 index 7effdb4..0000000 --- a/app/src/main/java/com/afollestad/nocknock/util/AlarmUtil.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.afollestad.nocknock.util; - -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.util.Log; - -import com.afollestad.nocknock.api.ServerModel; -import com.afollestad.nocknock.services.CheckService; - -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -/** - * @author Aidan Follestad (afollestad) - */ -public class AlarmUtil { - - private 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 deleted file mode 100644 index 160927b..0000000 --- a/app/src/main/java/com/afollestad/nocknock/util/JsUtil.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.afollestad.nocknock.util; - -import android.util.Log; - -import org.mozilla.javascript.Context; -import org.mozilla.javascript.EvaluatorException; -import org.mozilla.javascript.Function; -import org.mozilla.javascript.Scriptable; - -/** - * @author Aidan Follestad (afollestad) - */ -public class JsUtil { - - public static String exec(String code, String response) { - try { - final String func = String.format( - "function validate(response) { " + - "try { " + - "%s " + - "} catch(e) { " + - "return e; " + - "} " + - "}", code.replace("\n", " ")); - - // Every Rhino VM begins with the enter() - // This Context is not Android's Context - Context rhino = Context.enter(); - - // Turn off optimization to make Rhino Android compatible - rhino.setOptimizationLevel(-1); - try { - Scriptable scope = rhino.initStandardObjects(); - - // Note the forth argument is 1, which means the JavaScript source has - // been compressed to only one line using something like YUI - rhino.evaluateString(scope, func, "JavaScript", 1, null); - - // Get the functionName defined in JavaScriptCode - Function jsFunction = (Function) scope.get("validate", scope); - - // Call the function with params - Object jsResult = jsFunction.call(rhino, scope, scope, new Object[]{response}); - - // Parse the jsResult object to a String - String result = Context.toString(jsResult); - - boolean success = result != null && result.equals("true"); - String message = "The script returned a value other than true!"; - if (!success && result != null && !result.equals("false")) { - if (result.equals("undefined")) { - message = "The script did not return or throw anything!"; - } else { - message = result; - } - } - - Log.d("JsUtil", "Evaluated to " + message + " (" + success + "): " + code); - return !success ? message : null; - } finally { - Context.exit(); - } - } catch (EvaluatorException e) { - return e.getMessage(); - - - } - } - - private JsUtil() { - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java b/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java deleted file mode 100644 index 6cb885b..0000000 --- a/app/src/main/java/com/afollestad/nocknock/util/MathUtil.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.afollestad.nocknock.util; - -import android.graphics.Path; -import android.support.design.widget.FloatingActionButton; -import android.view.View; - -/** - * @author Aidan Follestad (afollestad) - */ -public final class MathUtil { - - public static Path bezierCurve(FloatingActionButton fab, View rootView) { - final int fabCenterX = (int) (fab.getX() + fab.getMeasuredWidth() / 2); - final int fabCenterY = (int) (fab.getY() + fab.getMeasuredHeight() / 2); - - final int endCenterX = (rootView.getMeasuredWidth() / 2) - (fab.getMeasuredWidth() / 2); - final int endCenterY = (rootView.getMeasuredHeight() / 2) - (fab.getMeasuredHeight() / 2); - - final int halfX = (fabCenterX - endCenterX) / 2; - final int halfY = (fabCenterY - endCenterY) / 2; - int mControlX = endCenterX + halfX; - int mControlY = endCenterY + halfY; - mControlY -= halfY; - mControlX += halfX; - - Path path = new Path(); - path.moveTo(fab.getX(), fab.getY()); - path.quadTo(mControlX, mControlY, endCenterX, endCenterY); - - return path; - } -} \ 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 deleted file mode 100644 index 3680f26..0000000 --- a/app/src/main/java/com/afollestad/nocknock/util/NetworkUtil.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.afollestad.nocknock.util; - -import android.content.Context; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; - -/** - * @author Aidan Follestad (afollestad) - */ -public class NetworkUtil { - - public static boolean hasInternet(Context context) { - final ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - final NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); - return activeNetwork != null && - activeNetwork.isConnectedOrConnecting(); - } -} \ 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 deleted file mode 100644 index 3a65b73..0000000 --- a/app/src/main/java/com/afollestad/nocknock/util/TimeUtil.java +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index cc4396a..0000000 --- a/app/src/main/java/com/afollestad/nocknock/views/DividerItemDecoration.java +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index ac8cf13..0000000 --- a/app/src/main/java/com/afollestad/nocknock/views/StatusImageView.java +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 900d3cc..0000000 --- a/app/src/main/res/anim/fade_out.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ 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 new file mode 100644 index 0000000..8e7f4df --- /dev/null +++ b/app/src/main/res/color/unchecked_chip_text.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi-v11/ic_notification.png b/app/src/main/res/drawable-hdpi-v11/ic_notification.png deleted file mode 100644 index 5c51b0b..0000000 Binary files a/app/src/main/res/drawable-hdpi-v11/ic_notification.png and /dev/null 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 deleted file mode 100644 index 610685d..0000000 Binary files a/app/src/main/res/drawable-mdpi-v11/ic_notification.png and /dev/null 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 deleted file mode 100644 index 88e634f..0000000 Binary files a/app/src/main/res/drawable-xhdpi-v11/ic_notification.png and /dev/null 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 deleted file mode 100644 index dc4957d..0000000 Binary files a/app/src/main/res/drawable-xxhdpi-v11/ic_notification.png and /dev/null 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 deleted file mode 100644 index af4779b..0000000 Binary files a/app/src/main/res/drawable-xxxhdpi-v11/ic_notification.png and /dev/null differ diff --git a/app/src/main/res/drawable/checked_chip.xml b/app/src/main/res/drawable/checked_chip.xml new file mode 100644 index 0000000..85010f5 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/checked_chip_pressed.xml b/app/src/main/res/drawable/checked_chip_pressed.xml new file mode 100644 index 0000000..0d7c176 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip_pressed.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/checked_chip_selector.xml b/app/src/main/res/drawable/checked_chip_selector.xml new file mode 100644 index 0000000..fa9df00 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml index 98beb0c..7561a3c 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 deleted file mode 100644 index 31bc563..0000000 --- a/app/src/main/res/drawable/green_circle.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - \ 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 f4977c9..241f9be 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 d85947c..901c3e1 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 85eb103..5175bda 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 7b18128..c0ca276 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 new file mode 100644 index 0000000..00fc15d --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_down_arrow.xml b/app/src/main/res/drawable/ic_down_arrow.xml deleted file mode 100644 index 2341755..0000000 --- a/app/src/main/res/drawable/ic_down_arrow.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/red_circle.xml b/app/src/main/res/drawable/red_circle.xml deleted file mode 100644 index d6bdd26..0000000 --- a/app/src/main/res/drawable/red_circle.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - \ 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 deleted file mode 100644 index 6f67037..0000000 --- a/app/src/main/res/drawable/status_error.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ 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 deleted file mode 100644 index 3ec2bc9..0000000 --- a/app/src/main/res/drawable/status_ok.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ 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 deleted file mode 100644 index c100e60..0000000 --- a/app/src/main/res/drawable/status_progress.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ 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 new file mode 100644 index 0000000..1864bc5 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/unchecked_chip_pressed.xml b/app/src/main/res/drawable/unchecked_chip_pressed.xml new file mode 100644 index 0000000..c387d70 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip_pressed.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/unchecked_chip_selector.xml b/app/src/main/res/drawable/unchecked_chip_selector.xml new file mode 100644 index 0000000..ba01f74 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/yellow_circle.xml b/app/src/main/res/drawable/yellow_circle.xml deleted file mode 100644 index 1201e65..0000000 --- a/app/src/main/res/drawable/yellow_circle.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - \ 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 4bf5e29..80f83da 100644 --- a/app/src/main/res/layout/activity_addsite.xml +++ b/app/src/main/res/layout/activity_addsite.xml @@ -1,242 +1,222 @@ - + > - + + + + android:layout_height="match_parent" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:orientation="horizontal" + > - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -