Compare commits

..

114 commits

Author SHA1 Message Date
Aidan Follestad
23ba4a69cd
Delete .travis.yml 2020-02-24 11:50:16 -08:00
Aidan Follestad
dd9aec1dff
Update README.md 2020-02-24 11:50:05 -08:00
Aidan Follestad
406af590aa Increment version code 2019-04-19 10:52:10 -07:00
Aidan Follestad
550f8c59be Adaptive-ish icon 2019-04-19 10:51:54 -07:00
Aidan Follestad
10d7fe33f9 Fix a few test cases 2019-04-18 19:09:08 -07:00
Aidan Follestad
35eda8f057 Exclude META-INF/atomicfu.kotlin_module from data module 2019-04-18 18:35:20 -07:00
Aidan Follestad
a0fd44ae7a Exclude META-INF/atomicfu.kotlin_module from common module 2019-04-18 18:06:27 -07:00
Aidan Follestad
351f718df8 0.8.8 2019-04-18 17:57:20 -07:00
Aidan Follestad
e2f7db22d1 Still trying to fix Travis 2019-04-18 17:48:27 -07:00
Aidan Follestad
82c1a17c68 Fix crash when mremoving headers, resolves #48 2019-04-18 17:36:49 -07:00
Aidan Follestad
a6670e2bea Fix Travis build 2019-04-18 17:20:09 -07:00
Aidan Follestad
5fc1569099 Remove donation options 2019-04-18 16:19:10 -07:00
Aidan Follestad
0770db5df5 Attempt to fix Travis.ci running UI tests 2019-04-18 16:12:06 -07:00
Aidan Follestad
97a0eda92c Dep and Gradle Plugin updates 2019-04-18 16:07:24 -07:00
Aidan Follestad
1ccb89bfc3 Dependency upgrades 2019-04-15 20:46:36 -07:00
Aidan Follestad
9ea9c78099 Add privacy policy link to the about dialog 2019-04-15 20:37:35 -07:00
Aidan Follestad
997c797598
Kotlin 1.3.30 2019-04-11 12:56:23 -07:00
Aidan Follestad
b26543d244
Update dependencies.gradle 2019-03-16 12:08:29 -07:00
Aidan Follestad
8c3654c4ac 0.8.7 2019-03-14 15:06:38 -07:00
Aidan Follestad
df2652860e -am 2019-03-14 15:06:03 -07:00
Aidan Follestad
4da8cb5f11 Fix tests 2019-03-14 15:01:24 -07:00
Aidan Follestad
334e9e823c Switch to Firebase for Crashlytics 2019-03-14 13:55:19 -07:00
Aidan Follestad
6d382b93a5 Dependency updates 2019-02-21 14:43:26 -08:00
Aidan Follestad
ef18464728
vvalidator 0.3.0 2019-02-01 17:57:23 -08:00
Aidan Follestad
872e99d80d Custom repo not needed anymore for Material Dialogs 2019-02-01 15:21:11 -08:00
Aidan Follestad
7f507792a8
Update gradle-wrapper.properties 2019-01-30 13:53:10 -08:00
Aidan Follestad
68b6944542 0.8.6c 2019-01-26 18:42:16 -08:00
Aidan Follestad
e39093b526 Fix script input layout background color 2019-01-26 18:41:55 -08:00
Aidan Follestad
9514a5ec83 0.8.6b 2019-01-26 14:55:26 -08:00
Aidan Follestad
3e5b1d4d8e Resolve two crashes 2019-01-26 14:55:06 -08:00
Aidan Follestad
de59bf9ec1 0.8.6 2019-01-26 14:41:02 -08:00
Aidan Follestad
0fbd27b54b Fixed some data population issues 2019-01-26 14:35:34 -08:00
Aidan Follestad
33388bd5c2 Fixes around default SSL cert setting 2019-01-26 14:27:55 -08:00
Aidan Follestad
75297c7ff5 Fixes to vvalidator setup 2019-01-26 14:22:22 -08:00
Aidan Follestad
c6fca52fe4 Use vvalidator in the view site page 2019-01-26 14:10:58 -08:00
Aidan Follestad
b3f8a43f71 Use vvalidator in the add site page 2019-01-26 13:55:34 -08:00
Aidan Follestad
7dc4ee7fb1 Add vvalidator dep 2019-01-26 12:39:46 -08:00
Aidan Follestad
859dcb53ca MD rc9 2019-01-26 12:36:20 -08:00
Aidan Follestad
f86ccbbe0c Add default Fabric props that should fix CI 2019-01-25 11:08:18 -08:00
Aidan Follestad
571e7ebff3 Pull Fabric props from environment variables, default to 0 which stops build failures 2019-01-24 16:03:12 -08:00
Aidan Follestad
77f939b095 Lots of dependency upgrades 2019-01-24 14:32:38 -08:00
Aidan Follestad
8f16ff2d33 AppDatabaseTest fix 2019-01-13 15:16:33 -08:00
Aidan Follestad
4f5fec758e 0.8.5 2019-01-11 22:09:06 -08:00
Aidan Follestad
b369f9dfd3 Update MainViewModelTest 2019-01-11 21:03:57 -08:00
Aidan Follestad
38c8c92c1c Fix yet another test folder 2019-01-11 20:05:32 -08:00
Aidan Follestad
6ae85ea061 Update NockNotificationManagerTest 2019-01-11 20:05:19 -08:00
Aidan Follestad
34329f3a9f Fix another test folder 2019-01-11 19:57:52 -08:00
Aidan Follestad
6bb131fb23 Update ValidationExecutorTest 2019-01-11 19:57:29 -08:00
Aidan Follestad
8535a6fe8b Fix test folder 2019-01-11 19:08:33 -08:00
Aidan Follestad
cd1651672f Basic certificate URI validation 2019-01-11 19:07:51 -08:00
Aidan Follestad
26d6d9abf8 Re-organize some UI, hook up SSL certificate selection, etc. Resolves #42. 2019-01-11 18:50:08 -08:00
Aidan Follestad
909e5420ad Integrate SslManager into the ValidationExecutor 2019-01-11 18:12:55 -08:00
Aidan Follestad
55ea6674e6 Add SslManager 2019-01-11 17:56:21 -08:00
Aidan Follestad
2221c45789 Use big text notification style for error notifications 2019-01-11 17:43:24 -08:00
Aidan Follestad
deae0f0dc2 Update the showcase image 2019-01-08 17:00:16 -08:00
Aidan Follestad
f207ed5f78 0.8.4 2019-01-08 16:51:31 -08:00
Aidan Follestad
cbac2796aa Show success notification if validation passes after previously not passing. Resolves #4. 2019-01-08 16:39:00 -08:00
Aidan Follestad
e3820fd7d3 Show more detail in error notifications 2019-01-08 16:30:18 -08:00
Aidan Follestad
8dc2112e2d Since headers can now be sent, consider 401 an error code 2019-01-08 16:27:26 -08:00
Aidan Follestad
74f7aa8aa2 Add the ability to duplicate sites in the long-press menu. Resolves #40. 2019-01-08 16:21:26 -08:00
Aidan Follestad
646bc25232 Improved a lot of the UI, cleaned up some stuff. Add the ability to add headers to sites, resolves #39. 2019-01-08 16:14:08 -08:00
Aidan Follestad
26ab76b363 Avoid divide by zero crash in RetryPolicy 2019-01-08 10:46:02 -08:00
Aidan Follestad
56030af0f0 Use issue/PR templates 2019-01-08 10:11:04 -08:00
Aidan Follestad
7f8db7b7d5 Update showcase image 2019-01-07 23:34:30 -08:00
Aidan Follestad
d293a83240 0.8.3 2019-01-07 23:27:54 -08:00
Aidan Follestad
002149cd3f Tag system, resolves #13 2019-01-07 22:52:31 -08:00
Aidan Follestad
2756fc9fc7 Groundwork for tags 2019-01-07 21:31:38 -08:00
Aidan Follestad
67aa54ac22 Move model to models subpackage 2019-01-07 18:09:32 -08:00
Aidan Follestad
2fe6f171ba Show app version in about dialog title 2019-01-07 17:16:30 -08:00
Aidan Follestad
8a4c7448f5 0.8.2b 2019-01-07 17:13:31 -08:00
Aidan Follestad
6ff3dfc214 Darken the light theme divider slightly 2019-01-07 17:12:57 -08:00
Aidan Follestad
5cd7127e4f Move retry policy input down, add a footer to it 2019-01-07 17:12:05 -08:00
Aidan Follestad
b153622a93 Even out view spacing in add site screen 2019-01-07 16:59:18 -08:00
Aidan Follestad
2585ed77b9 0.8.2 2019-01-07 16:01:47 -08:00
Aidan Follestad
69d9eb094e Retry policy functionality works, resolves #30 2019-01-07 15:50:04 -08:00
Aidan Follestad
31c9e94e15 Fix a field name typo 2019-01-07 14:56:12 -08:00
Aidan Follestad
da7623db79 Add data model and UI for retry policy, part of #30 2019-01-07 14:55:03 -08:00
Aidan Follestad
09352724ee Fix a string in the donation dialog 2019-01-07 12:54:21 -08:00
Aidan Follestad
19f58ef2e6 Fix a crash 2019-01-07 12:52:34 -08:00
Aidan Follestad
6daebe46eb Add Fabric for automatic crash reporting 2019-01-07 10:52:04 -08:00
Aidan Follestad
2f4c7126db Update the showcase image 2019-01-06 22:57:55 -08:00
Aidan Follestad
aa4bebf1ce Divider color tweaks 2019-01-06 22:50:02 -08:00
Aidan Follestad
1d2b79d5a3 0.8.1 2019-01-06 22:40:27 -08:00
Aidan Follestad
44d31dd5c3 Theme tweaks, update notification icon 2019-01-06 21:38:27 -08:00
Aidan Follestad
1569871524 Cleanup 2019-01-06 21:33:54 -08:00
Aidan Follestad
de36a2f5e6 Material Design 2 esque UI and a dark mode. Resolves #37, resolves #38. 2019-01-06 21:29:07 -08:00
Aidan Follestad
84eb0b30e1 Dependency updates 2019-01-06 20:00:29 -08:00
Aidan Follestad
48735bb606 Back to Gradle 4.10 2019-01-06 19:51:59 -08:00
Aidan Follestad
a8d7f85c9b Update showcase image 2019-01-02 21:09:50 -08:00
Aidan Follestad
4c3c22eb8d Android Studio stopped recognizing kotlin folders as source root automatically. Renamed main ones to java again 2018-12-12 21:42:15 -08:00
Aidan Follestad
a8626b4d29 Add .editorconfig 2018-12-12 20:56:13 -08:00
Aidan Follestad
b7c51a12ed Fix DiffUtil usage 2018-12-07 00:38:43 -08:00
Aidan Follestad
b09088ab9e String tweak 2018-12-07 00:30:50 -08:00
Aidan Follestad
8effe38a1a More View <-> LiveData connection fixes 2018-12-07 00:27:00 -08:00
Aidan Follestad
3b1aae66f3 ValidateJob must retrieve the job ID as a long 2018-12-07 00:19:41 -08:00
Aidan Follestad
76a5a46454 Add site view model should have default values 2018-12-07 00:17:46 -08:00
Aidan Follestad
fc6bdf1c39 View <-> LiveData connection tweaks, some re-org 2018-12-07 00:11:13 -08:00
Aidan Follestad
9a849ab8ac Koin setup tweaks 2018-12-06 22:01:45 -08:00
Aidan Follestad
8470b65df1 ViewSiteViewModelTest 2018-12-06 20:27:38 -08:00
Aidan Follestad
b57f645c98 AddSiteViewModelTest 2018-12-06 19:41:12 -08:00
Aidan Follestad
98327c8c5b Fix StatusUpdateIntentReceiverTest 2018-12-06 18:10:51 -08:00
Aidan Follestad
1e92644904 Switch from Dagger to Koin, resolves #35 2018-12-06 17:56:51 -08:00
Aidan Follestad
c9750f5f66 Initial implementation of a presenter-less app, just using view models. 2018-12-06 13:05:43 -08:00
Aidan Follestad
88ae30c0c9 Switch from SQLite to Room 2018-12-05 13:30:04 -08:00
Aidan Follestad
cad589eebc Update license header 2018-12-03 12:45:11 -08:00
Aidan Follestad
f9711137b9 Add AVD to Travis CI builder 2018-12-03 10:36:34 -08:00
Aidan Follestad
38d7bcb7f9 Change how ServerModelDbHelper does DB upgrade checks 2018-12-02 14:49:39 -08:00
Aidan Follestad
62ef385b65 Configurable response timeouts, resolves #31 2018-12-02 13:36:46 -08:00
Aidan Follestad
7e46b84d08 Undid changes to .travis.yml for now 2018-12-01 21:00:04 -08:00
Aidan Follestad
92878c875e Fix ViewPresenterTest 2018-12-01 20:44:18 -08:00
Aidan Follestad
8a1816c3e8 Update .travis.ci for Codecov 2018-12-01 20:03:13 -08:00
Aidan Follestad
14a86568e6 Unit tests for CheckStatusManager 2018-12-01 19:58:09 -08:00
Aidan Follestad
b8dd2c0d24 Instrumentation tests for ServerModelStore 2018-12-01 18:21:14 -08:00
Aidan Follestad
03c687def5 Unit tests for NockNotificationManager 2018-12-01 17:36:14 -08:00
244 changed files with 9437 additions and 4101 deletions

View file

@ -1,28 +0,0 @@
(`[x]` becomes a filled in checkbox, `[ ]` is an empty one)
- [ ] I have verified there are [no duplicate active or recent bugs, questions, or requests](https://github.com/afollestad/nock-nock/issues?q=is%3Aissue+is%3Aclosed)
- [ ] I have given my issue a non-generic title.
---
If this is a improvement or feature request, you can remove everything below.
Also, please consider making a pull request if you are capable of contributing.
###### Include the following:
- Nock Nock version: `0.x.x`
- Affected device: Google Pixel 3 XL with Android 9.0
---
###### Reproduction Steps
1.
---
###### Expected Result
---
###### Actual Result

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

@ -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.

View file

@ -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.

View file

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

4
.gitignore vendored
View file

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

17
.idea/misc.xml generated
View file

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

2
.idea/modules.xml generated
View file

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

View file

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

View file

@ -1,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.
<br/>
<a href="https://play.google.com/store/apps/details?id=com.afollestad.nocknock&utm_source=global_co&utm_medium=prtnr&utm_content=Mar2515&utm_campaign=PartBadge&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1"><img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" width="200px"/></a>
<a href="https://play.google.com/store/apps/details?id=com.afollestad.nocknock&utm_source=global_co&utm_medium=prtnr&utm_content=Mar2515&utm_campaign=PartBadge&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1"><img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" width="200px"/></a>

View file

@ -15,30 +15,65 @@ android {
versionCode versions.publishVersionCode
versionName versions.publishVersion
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
}
dependencies {
implementation project(':data')
implementation project(':utilities')
implementation project(':common')
implementation project(':engine')
implementation project(':data')
implementation project(':notifications')
implementation project(':viewcomponents')
implementation 'androidx.appcompat:appcompat:' + versions.androidx
implementation 'androidx.recyclerview:recyclerview:' + versions.androidx
implementation 'com.google.android.material:material:' + versions.androidx
// 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
implementation 'com.google.dagger:dagger:' + versions.dagger
kapt 'com.google.dagger:dagger-compiler:' + versions.dagger
// 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: '../spotless.gradle'
apply from: '../mock/mock.gradle'
apply plugin: "io.fabric"
apply plugin: 'com.google.gms.google-services'

Binary file not shown.

View file

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

View file

@ -1,16 +1,32 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock
import android.app.Activity
import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import androidx.core.text.HtmlCompat.fromHtml
import com.afollestad.materialdialogs.utils.MDUtil.resolveColor
import com.afollestad.nocknock.utilities.ext.toUri
import com.afollestad.nocknock.utilities.ui.toast
typealias ActivityLifeChange = (activity: Activity, resumed: Boolean) -> Unit
@ -40,3 +56,37 @@ fun Application.onActivityLifeChange(cb: ActivityLifeChange) {
}
fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY)
fun Activity.viewUrl(url: String) {
val customTabsIntent = CustomTabsIntent.Builder()
.apply {
setToolbarColor(resolveColor(this@viewUrl, attr = R.attr.colorPrimary))
}
.build()
try {
customTabsIntent.launchUrl(this, url.toUri())
} catch (_: ActivityNotFoundException) {
toast(R.string.install_web_browser)
}
}
fun Activity.viewUrlWithApp(
url: String,
pkg: String
) {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = url.toUri()
}
val resInfo = packageManager.queryIntentActivities(intent, 0)
for (info in resInfo) {
if (info.activityInfo.packageName.toLowerCase().contains(pkg) ||
info.activityInfo.name.toLowerCase().contains(pkg)
) {
startActivity(intent.apply {
setPackage(info.activityInfo.packageName)
})
return
}
}
viewUrl(url)
}

View file

@ -1,62 +1,69 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("unused")
package com.afollestad.nocknock
import android.app.Application
import android.util.Log
import com.afollestad.nocknock.di.AppComponent
import com.afollestad.nocknock.di.DaggerAppComponent
import com.afollestad.nocknock.engine.statuscheck.BootReceiver
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob
import com.afollestad.nocknock.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.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.main.MainActivity
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.Injector
import com.afollestad.nocknock.utilities.ext.systemService
import okhttp3.OkHttpClient
import javax.inject.Inject
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(), Injector {
companion object {
private fun log(message: String) {
if (BuildConfig.DEBUG) {
Log.d("NockNockApp", message)
}
}
}
private lateinit var appComponent: AppComponent
@Inject lateinit var nockNotificationManager: NockNotificationManager
class NockNockApp : Application() {
private var resumedActivities: Int = 0
override fun onCreate() {
super.onCreate()
val okHttpClient = OkHttpClient.Builder()
.addNetworkInterceptor { chain ->
val request = chain.request()
.newBuilder()
.addHeader("User-Agent", "com.afollestad.nocknock")
.build()
chain.proceed(request)
}
.build()
if (DEBUG) {
Timber.plant(DebugTree())
}
appComponent = DaggerAppComponent.builder()
.application(this)
.okHttpClient(okHttpClient)
.jobScheduler(systemService(JOB_SCHEDULER_SERVICE))
.notificationManager(systemService(NOTIFICATION_SERVICE))
.build()
appComponent.inject(this)
Timber.plant(FabricTree())
Fabric.with(this, Crashlytics())
val modules = listOf(
prefModule,
mainModule,
engineModule,
commonModule,
notificationsModule,
viewModelModule
)
startKoin(
androidContext = this,
modules = modules
)
val nockNotificationManager by inject<NockNotificationManager>()
onActivityLifeChange { activity, resumed ->
if (resumed) {
resumedActivities++
@ -69,13 +76,4 @@ class NockNockApp : Application(), Injector {
nockNotificationManager.setIsAppOpen(resumedActivities > 0)
}
}
override fun injectInto(target: Any) = when (target) {
is MainActivity -> appComponent.inject(target)
is ViewSiteActivity -> appComponent.inject(target)
is AddSiteActivity -> appComponent.inject(target)
is CheckStatusJob -> appComponent.inject(target)
is BootReceiver -> appComponent.inject(target)
else -> throw IllegalStateException("Can't inject into $target")
}
}

View file

@ -1,18 +1,30 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil.calculateDiff
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.isPending
import com.afollestad.nocknock.data.textRes
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
@ -20,12 +32,12 @@ import kotlinx.android.synthetic.main.list_item_server.view.textName
import kotlinx.android.synthetic.main.list_item_server.view.textStatus
import kotlinx.android.synthetic.main.list_item_server.view.textUrl
typealias Listener = (model: ServerModel, longClick: Boolean) -> Unit
typealias Listener = (model: Site, longClick: Boolean) -> Unit
/** @author Aidan Follestad (@afollestad) */
class ServerVH constructor(
class SiteViewHolder constructor(
itemView: View,
private val adapter: ServerAdapter
private val adapter: SiteAdapter
) : RecyclerView.ViewHolder(itemView), View.OnLongClickListener {
init {
@ -35,24 +47,32 @@ class ServerVH constructor(
itemView.setOnLongClickListener(this)
}
fun bind(model: ServerModel) {
fun bind(model: Site) {
requireNotNull(model.settings) { "Settings must be populated." }
itemView.textName.text = model.name
itemView.textUrl.text = model.url
itemView.iconStatus.setStatus(model.status)
val statusText = model.status.textRes()
if (statusText == 0) {
itemView.textStatus.text = model.reason
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.textStatus.setText(statusText)
itemView.iconStatus.setStatus(WAITING)
itemView.textStatus.setText(R.string.none)
}
val res = itemView.resources
when {
model.disabled -> {
model.settings?.disabled == true -> {
itemView.textInterval.setText(R.string.checks_disabled)
}
model.status.isPending() -> {
model.lastResult?.status.isPending() -> {
itemView.textInterval.text = res.getString(
R.string.next_check_x,
res.getString(R.string.now)
@ -74,70 +94,33 @@ class ServerVH constructor(
}
/** @author Aidan Follestad (@afollestad) */
class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter<ServerVH>() {
class SiteAdapter(private val listener: Listener) : RecyclerView.Adapter<SiteViewHolder>() {
private val models = mutableListOf<ServerModel>()
private var models = mutableListOf<Site>()
internal fun performClick(
index: Int,
longClick: Boolean
) = listener.invoke(models[index], longClick)
fun add(model: ServerModel) {
models.add(model)
notifyItemInserted(models.size - 1)
}
fun update(target: ServerModel) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
update(i, target)
break
}
}
}
private fun update(
index: Int,
model: ServerModel
) {
models[index] = model
notifyItemChanged(index)
}
fun remove(index: Int) {
models.removeAt(index)
notifyItemRemoved(index)
}
fun remove(target: ServerModel) {
for ((i, model) in models.withIndex()) {
if (model.id == target.id) {
remove(i)
break
}
}
}
fun set(newModels: List<ServerModel>) {
this.models.clear()
if (!newModels.isEmpty()) {
this.models.addAll(newModels)
}
notifyDataSetChanged()
fun set(newModels: List<Site>) {
val formerModels = this.models
this.models = newModels.toMutableList()
val diffResult = calculateDiff(SiteDiffCallback(formerModels, this.models))
diffResult.dispatchUpdatesTo(this)
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ServerVH {
): SiteViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_server, parent, false)
return ServerVH(v, this)
return SiteViewHolder(v, this)
}
override fun onBindViewHolder(
holder: ServerVH,
holder: SiteViewHolder,
position: Int
) {
val model = models[position]

View file

@ -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<Site>,
private val newItems: List<Site>
) : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int
) = oldItems[oldItemPosition].id == newItems[newItemPosition].id
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
) = oldItems[oldItemPosition] == newItems[newItemPosition]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,17 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.dialogs
@ -10,6 +20,7 @@ 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) */
@ -24,8 +35,9 @@ class AboutDialog : DialogFragment() {
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return MaterialDialog(activity!!)
.title(R.string.about)
val context = activity ?: throw IllegalStateException("Oh no!")
return MaterialDialog(context)
.title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME))
.positiveButton(R.string.dismiss)
.message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
}

View file

@ -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<JobScheduler> {
get<Application>().systemService(JOB_SCHEDULER_SERVICE)
}
single<NotificationManager> {
get<Application>().systemService(NOTIFICATION_SERVICE)
}
}

View file

@ -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<RxkPrefs>().boolean(PREF_DARK_MODE, false)
}
}

View file

@ -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)
)
}
}

View file

@ -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)
}
}
}

View file

@ -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<Pref<Boolean>>(name = PREF_DARK_MODE)
override fun onCreate(savedInstanceState: Bundle?) {
isDarkModeEnabled = isDarkMode()
setTheme(themeRes())
super.onCreate(savedInstanceState)
if (getCurrentNightMode() == UNKNOWN) {
darkModePref.observe()
.filter { it != isDarkModeEnabled }
.subscribe {
log("Theme changed, recreating Activity.")
recreate()
}
.attachLifecycle(this)
}
}
protected fun getCurrentNightMode(): NightMode {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return UNKNOWN
}
return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
Configuration.UI_MODE_NIGHT_YES -> return ENABLED
Configuration.UI_MODE_NIGHT_NO -> return DISABLED
else -> UNKNOWN
}
}
protected fun isDarkMode(): Boolean {
return when (getCurrentNightMode()) {
ENABLED -> true
DISABLED -> false
else -> darkModePref.get()
}
}
protected fun toggleDarkMode() = setDarkMode(!isDarkMode())
private fun setDarkMode(darkMode: Boolean) = darkModePref.set(darkMode)
private fun themeRes() = if (isDarkMode()) {
R.style.AppTheme_Dark
} else {
R.style.AppTheme
}
}

View file

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

View file

@ -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()
}

View file

@ -1,88 +1,137 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.addsite
import android.annotation.SuppressLint
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ValidationMode
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.indexToValidationMode
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.conceal
import com.afollestad.nocknock.viewcomponents.ext.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.onLayout
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
import 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.doneBtn
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.rootView
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.toolbar
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
import kotlin.math.max
import kotlin.properties.Delegates.notNull
const val KEY_FAB_X = "fab_x"
const val KEY_FAB_Y = "fab_y"
const val KEY_FAB_SIZE = "fab_size"
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 : AppCompatActivity(), AddSiteView {
class AddSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
var isClosing: Boolean = false
var revealCx by notNull<Int>()
var revealCy by notNull<Int>()
var revealRadius by notNull<Float>()
@Inject lateinit var presenter: AddSitePresenter
private val viewModel by viewModel<AddSiteViewModel>()
private lateinit var validationForm: Form
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_addsite)
presenter.takeView(this)
setupUi()
setupValidation()
toolbar.setNavigationOnClickListener { closeActivityWithReveal() }
lifecycle.addObserver(viewModel)
if (savedInstanceState == null) {
rootView.conceal()
rootView.onLayout {
val fabSize = intent.getIntExtra(KEY_FAB_SIZE, 0)
val fabX = intent.getFloatExtra(KEY_FAB_X, 0f)
.toInt()
val fabY = intent.getFloatExtra(KEY_FAB_Y, 0f)
.toInt()
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as? Site
model?.let { viewModel.prePopulateFromModel(model) }
revealCx = fabX + fabSize / 2
revealCy = (fabY + toolbar.measuredHeight + fabSize / 2)
revealRadius = max(revealCx, revealCy).toFloat()
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
circularRevealActivity()
}
}
// Name
inputName.attachLiveData(this, viewModel.name)
inputUrl.setOnFocusChangeListener { _, hasFocus ->
presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
// 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(
@ -91,98 +140,96 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView {
resources.getStringArray(R.array.response_validation_options)
)
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
doneBtn.setOnClickListener {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
isClosing = true
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationMode.validationContent()
)
// SSL certificate
sslCertificateBrowse.setOnClickListener {
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
addCategory(CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
}
}
override fun onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun setLoading() = loadingProgress.setLoading()
override fun setDoneLoading() = loadingProgress.setDone()
override fun showOrHideUrlSchemeWarning(show: Boolean) {
textUrlWarning.showOrHide(show)
if (show) {
textUrlWarning.setText(R.string.warning_http_url)
}
}
override fun showOrHideValidationSearchTerm(show: Boolean) =
responseValidationSearchTerm.showOrHide(show)
override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show)
override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res)
override fun setInputErrors(errors: InputErrors) {
isClosing = false
inputName.error = if (errors.name != null) {
getString(errors.name!!)
} else {
null
}
inputUrl.error = if (errors.url != null) {
getString(errors.url!!)
} else {
null
}
checkIntervalLayout.setError(
if (errors.checkInterval != null) {
getString(errors.checkInterval!!)
} else {
null
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
)
responseValidationSearchTerm.error = if (errors.termSearch != null) {
getString(errors.termSearch!!)
} else {
null
}
scriptInputLayout.setError(
if (errors.javaScript != null) {
getString(errors.javaScript!!)
} else {
null
}
// 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 onSiteAdded() {
setResult(RESULT_OK)
finish()
overridePendingTransition(R.anim.fade_out, R.anim.fade_out)
override fun onResume() {
super.onResume()
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
override fun onBackPressed() = closeActivityWithReveal()
private fun ValidationMode.validationContent() = when (this) {
STATUS_CODE -> null
TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
JAVASCRIPT -> scriptInputLayout.getCode()
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
}

View file

@ -1,129 +1,134 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.main
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
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.ServerAdapter
import com.afollestad.nocknock.data.ServerModel
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.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import 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.rootView
import kotlinx.android.synthetic.main.activity_main.toolbar
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 javax.inject.Inject
import kotlin.coroutines.CoroutineContext
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 : AppCompatActivity(), MainView {
class MainActivity : DarkModeSwitchActivity() {
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) = presenter.onBroadcast(intent)
private val notificationManager by inject<NockNotificationManager>()
private val intentProvider by inject<IntentProvider>()
internal val viewModel by viewModel<MainViewModel>()
private lateinit var siteAdapter: SiteAdapter
private lateinit var tagAdapter: TagAdapter
private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) {
viewModel.postSiteUpdate(it)
}
}
@Inject lateinit var presenter: MainPresenter
private lateinit var adapter: ServerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_main)
presenter.takeView(this)
setupUi()
toolbar.inflateMenu(R.menu.menu_main)
toolbar.setOnMenuItemClickListener { item ->
if (item.itemId == R.id.about) {
AboutDialog.show(this)
}
return@setOnMenuItemClickListener true
notificationManager.createChannels()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
adapter = ServerAdapter(this::onSiteSelected)
list.layoutManager = LinearLayoutManager(this)
list.adapter = adapter
list.addItemDecoration(DividerItemDecoration(this, VERTICAL))
fab.setOnClickListener { addSite() }
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)
}
override fun onResume() {
super.onResume()
val filter = IntentFilter().apply {
addAction(ACTION_STATUS_UPDATE)
}
safeRegisterReceiver(intentReceiver, filter)
presenter.resume()
}
override fun onPause() {
super.onPause()
safeUnregisterReceiver(intentReceiver)
}
override fun onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun setModels(models: List<ServerModel>) {
list.post {
adapter.set(models)
emptyText.showOrHide(models.isEmpty())
}
}
override fun updateModel(model: ServerModel) {
list.post { adapter.update(model) }
}
override fun onSiteDeleted(model: ServerModel) {
list.post {
adapter.remove(model)
emptyText.showOrHide(adapter.itemCount == 0)
}
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
private fun onSiteSelected(
model: ServerModel,
model: Site,
longClick: Boolean
) {
if (longClick) {
@ -131,8 +136,9 @@ class MainActivity : AppCompatActivity(), MainView {
title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
0 -> presenter.refreshSite(model)
1 -> maybeRemoveSite(model)
0 -> viewModel.refreshSite(model)
1 -> addSiteForDuplication(model)
2 -> maybeRemoveSite(model)
}
}
}

View file

@ -1,63 +1,73 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.main
import android.content.Intent
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.toHtml
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.addsite.KEY_FAB_SIZE
import com.afollestad.nocknock.ui.addsite.KEY_FAB_X
import com.afollestad.nocknock.ui.addsite.KEY_FAB_Y
import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL
import com.afollestad.nocknock.ui.viewsite.KEY_SITE
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL
import kotlinx.android.synthetic.main.activity_main.fab
internal const val VIEW_SITE_RQ = 6923
internal const val ADD_SITE_RQ = 6969
// ADD
internal fun MainActivity.addSite() {
startActivityForResult(intentToAdd(fab.x, fab.y, fab.measuredWidth), ADD_SITE_RQ)
startActivityForResult(intentToAdd(), ADD_SITE_RQ)
}
private fun MainActivity.intentToAdd(
x: Float,
y: Float,
size: Int
) = Intent(this, AddSiteActivity::class.java).apply {
putExtra(KEY_FAB_X, x)
putExtra(KEY_FAB_Y, y)
putExtra(KEY_FAB_SIZE, size)
addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
internal fun MainActivity.addSiteForDuplication(site: Site) {
startActivityForResult(intentToAdd(site), ADD_SITE_RQ)
}
internal fun MainActivity.viewSite(model: ServerModel) {
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: ServerModel) =
private fun MainActivity.intentToView(model: Site) =
Intent(this, ViewSiteActivity::class.java).apply {
putExtra(KEY_VIEW_MODEL, model)
putExtra(KEY_SITE, model)
}
internal fun MainActivity.maybeRemoveSite(model: ServerModel) {
// 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) { presenter.removeSite(model) }
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 ServerModel
val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as Site
viewSite(model)
}
}

View file

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

View file

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

View file

@ -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<List<Site>>()
private val isLoading = MutableLiveData<Boolean>()
private val emptyTextVisibility = MutableLiveData<Boolean>()
private val tags = MutableLiveData<List<String>>()
private val tagsListVisibility = MutableLiveData<Boolean>()
@CheckResult fun onSites(): LiveData<List<Site>> = sites
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onEmptyTextVisibility(): LiveData<Boolean> = emptyTextVisibility
@CheckResult fun onTags(): LiveData<List<String>> = tags
@CheckResult fun onTagsListVisibility(): LiveData<Boolean> = tagsListVisibility
@OnLifecycleEvent(ON_RESUME)
fun onResume() = loadSites(emptyList())
fun onTagSelection(tags: List<String>) = loadSites(tags)
fun postSiteUpdate(model: Site) {
val currentSites = sites.value ?: return
val index = currentSites.indexOfFirst { it.id == model.id }
if (index == -1) return
sites.value = currentSites.toMutableList()
.apply {
this[index] = model
}
}
fun refreshSite(model: Site) {
validationManager.scheduleValidation(
site = model,
rightNow = true,
cancelPrevious = true
)
}
fun removeSite(model: Site) {
validationManager.cancelScheduledValidation(model)
notificationManager.cancelStatusNotification(model)
scope.launch {
isLoading.value = true
withContext(ioDispatcher) { database.deleteSite(model) }
val currentSites = sites.value ?: return@launch
val index = currentSites.indexOfFirst { it.id == model.id }
isLoading.value = false
if (index == -1) return@launch
val newSitesList = currentSites.toMutableList()
.apply {
removeAt(index)
}
sites.value = newSitesList
emptyTextVisibility.value = newSitesList.isEmpty()
}
}
private fun loadSites(forTags: List<String>) {
scope.launch {
notificationManager.cancelStatusNotifications()
emptyTextVisibility.value = false
isLoading.value = true
val unfiltered = withContext(ioDispatcher) {
database.allSites()
}
var result = unfiltered
if (forTags.isNotEmpty()) {
result = result.filter { site ->
val itemTags = site.tags.toLowerCase()
.split(",")
itemTags.any { tag -> forTags.contains(tag) }
}
}
sites.value = result
ensureCheckJobs()
isLoading.value = false
emptyTextVisibility.value = result.isEmpty()
val tagsValues = pullOutTags(unfiltered)
tags.value = tagsValues
tagsListVisibility.value = tagsValues.isNotEmpty()
}
}
private suspend fun ensureCheckJobs() {
withContext(ioDispatcher) {
validationManager.ensureScheduledValidations()
}
}
private fun pullOutTags(sites: List<Site>): List<String> {
return mutableListOf<String>().apply {
for (site in sites) {
val splitTags = site.tags.toLowerCase()
.split(',')
splitTags
.filter { it.isNotEmpty() }
.forEach { tag ->
if (!this.contains(tag)) {
this.add(tag)
}
}
}
sort()
}
}
}

View file

@ -1,104 +1,184 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.viewsite
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.LAST_CHECK_NONE
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.ValidationMode
import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT
import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH
import com.afollestad.nocknock.data.indexToValidationMode
import com.afollestad.nocknock.data.textRes
import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE
import com.afollestad.nocknock.utilities.ext.ScopeReceiver
import com.afollestad.nocknock.utilities.ext.formatDate
import com.afollestad.nocknock.utilities.ext.injector
import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver
import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver
import com.afollestad.nocknock.utilities.ext.scopeWhileAttached
import com.afollestad.nocknock.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.onItemSelected
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.ext.trimmedText
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.disableChecksButton
import kotlinx.android.synthetic.main.activity_viewsite.doneBtn
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.rootView
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.toolbar
import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
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 : AppCompatActivity(), ViewSiteView {
class ViewSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
@Inject lateinit var presenter: ViewSitePresenter
internal val viewModel by viewModel<ViewSiteViewModel>()
private lateinit var validationForm: Form
private val intentReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent
) = presenter.onBroadcast(intent)
private val intentProvider by inject<IntentProvider>()
private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) {
viewModel.setModel(it)
}
}
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injector().injectInto(this)
setContentView(R.layout.activity_viewsite)
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(model)
setupUi()
setupValidation()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
// Status
viewModel.status.observe(this, Observer {
iconStatus.setStatus(it)
invalidateMenuForStatus(it)
})
// Name
inputName.attachLiveData(this, viewModel.name)
// Tags
inputTags.attachLiveData(this, viewModel.tags)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
// Validation mode
responseValidationMode.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationMode,
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
// Validation search term
responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// SSL certificate
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
viewModel.certificateUri.distinct()
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
// Headers
headersLayout.attach(viewModel.headers)
// Last/next check
viewModel.onLastCheckResultText()
.toViewText(this, textLastCheckResult)
viewModel.onNextCheckText()
.toViewText(this, textNextCheck)
}
private fun setupUi() {
toolbarTitle.text = ""
toolbar.run {
setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() }
inflateMenu(R.menu.menu_viewsite)
menu.findItem(R.id.refresh)
.setActionView(R.layout.menu_item_refresh_icon)
.apply {
actionView.setOnClickListener { presenter.checkNow() }
actionView.setOnClickListener { viewModel.checkNow() }
}
setOnMenuItemClickListener {
maybeRemoveSite()
return@setOnMenuItemClickListener true
when (it.itemId) {
R.id.remove -> maybeRemoveSite()
R.id.disableChecks -> maybeDisableChecks()
}
true
}
}
scrollView.onScroll {
toolbar.elevation = if (it > toolbar.height / 4) {
toolbar.dimenFloat(R.dimen.default_elevation)
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
inputUrl.setOnFocusChangeListener { _, hasFocus ->
presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText())
}
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
@ -107,150 +187,105 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView {
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
responseValidationMode.onItemSelected(presenter::onValidationModeSelected)
// Disabled button
viewModel.onDisableChecksVisibility()
.observe(this, Observer {
toolbar.menu.findItem(R.id.disableChecks)
.isVisible = it
})
doneBtn.setOnClickListener {
val checkInterval = checkIntervalLayout.getSelectedCheckInterval()
val validationMode =
responseValidationMode.selectedItemPosition.indexToValidationMode()
// Done item text
viewModel.onDoneButtonText()
.observe(this, Observer {
toolbar.menu.findItem(R.id.commit)
.setTitle(it)
})
presenter.commit(
name = inputName.trimmedText(),
url = inputUrl.trimmedText(),
checkInterval = checkInterval,
validationMode = validationMode,
validationContent = validationMode.validationContent()
)
// 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() }
}
}
disableChecksButton.setOnClickListener { maybeDisableChecks() }
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
visibility = viewModel.onValidationScriptVisibility(),
form = validationForm
)
presenter.takeView(this, intent)
// 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)
presenter.onNewIntent(intent)
}
override fun onDestroy() {
presenter.dropView()
super.onDestroy()
}
override fun setLoading() = loadingProgress.setLoading()
override fun setDoneLoading() = loadingProgress.setDone()
override fun showOrHideUrlSchemeWarning(show: Boolean) {
textUrlWarning.showOrHide(show)
if (show) {
textUrlWarning.setText(R.string.warning_http_url)
if (intent != null && intent.hasExtra(KEY_SITE)) {
val newModel = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(newModel)
}
}
override fun showOrHideValidationSearchTerm(show: Boolean) =
responseValidationSearchTerm.showOrHide(show)
override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show)
override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res)
override fun displayModel(model: ServerModel) = with(model) {
iconStatus.setStatus(this.status)
inputName.setText(this.name)
inputUrl.setText(this.url)
if (this.lastCheck == LAST_CHECK_NONE) {
textLastCheckResult.setText(R.string.none)
} else {
val statusText = this.status.textRes()
textLastCheckResult.text = if (statusText == 0) {
this.reason
} else {
getString(statusText)
}
}
if (this.disabled) {
textNextCheck.setText(R.string.auto_checks_disabled)
} else {
textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate()
}
checkIntervalLayout.set(this.checkInterval)
responseValidationMode.setSelection(validationMode.value - 1)
when (this.validationMode) {
TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "")
JAVASCRIPT -> scriptInputLayout.setCode(this.validationContent)
else -> {
responseValidationSearchTerm.setText("")
scriptInputLayout.clear()
}
}
disableChecksButton.showOrHide(!this.disabled)
doneBtn.setText(
if (this.disabled) R.string.renable_and_save_changes
else R.string.save_changes
)
invalidateMenuForStatus(model)
}
override fun setInputErrors(errors: InputErrors) {
inputName.error = if (errors.name != null) {
getString(errors.name!!)
} else {
null
}
inputUrl.error = if (errors.url != null) {
getString(errors.url!!)
} else {
null
}
checkIntervalLayout.setError(
if (errors.checkInterval != null) {
getString(errors.checkInterval!!)
} else {
null
}
)
responseValidationSearchTerm.error = if (errors.termSearch != null) {
getString(errors.termSearch!!)
} else {
null
}
scriptInputLayout.setError(
if (errors.javaScript != null) {
getString(errors.javaScript!!)
} else {
null
}
)
}
override fun scopeWhileAttached(
context: CoroutineContext,
exec: ScopeReceiver
) = rootView.scopeWhileAttached(context, exec)
override fun onResume() {
super.onResume()
val filter = IntentFilter().apply {
addAction(ACTION_STATUS_UPDATE)
}
safeRegisterReceiver(intentReceiver, filter)
}
override fun onPause() {
super.onPause()
safeUnregisterReceiver(intentReceiver)
}
private fun ValidationMode.validationContent() = when (this) {
STATUS_CODE -> null
TERM_SEARCH -> responseValidationSearchTerm.trimmedText()
JAVASCRIPT -> scriptInputLayout.getCode()
}
}

View file

@ -1,46 +1,59 @@
/*
* Licensed under Apache-2.0
*
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.viewsite
import android.widget.ImageView
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.ServerModel
import com.afollestad.nocknock.data.isPending
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.activity_viewsite.toolbar
import kotlinx.android.synthetic.main.include_app_bar.toolbar
const val KEY_SITE = "site_model"
internal fun ViewSiteActivity.maybeRemoveSite() {
val model = presenter.currentModel()
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) { presenter.removeSite() }
positiveButton(R.string.remove) {
viewModel.removeSite { finish() }
}
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.maybeDisableChecks() {
val model = presenter.currentModel()
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) { presenter.disableChecks() }
positiveButton(R.string.disable) { viewModel.disableSite() }
negativeButton(android.R.string.cancel)
}
}
internal fun ViewSiteActivity.invalidateMenuForStatus(model: ServerModel) {
internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) {
val refreshIcon = toolbar.menu.findItem(R.id.refresh)
.actionView as ImageView
if (model.status.isPending()) {
if (status.isPending()) {
refreshIcon.animateRotation()
} else {
refreshIcon.run {

View file

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

View file

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

View file

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

View file

@ -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()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,5 +4,5 @@
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/content_inset"
android:background="@color/dividerColorDark"
android:background="?dividerColor"
/>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,216 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock
import android.app.PendingIntent
import android.content.Intent
import android.content.IntentFilter
import com.afollestad.nocknock.data.AppDatabase
import com.afollestad.nocknock.data.HeaderDao
import com.afollestad.nocknock.data.RetryPolicyDao
import com.afollestad.nocknock.data.SiteDao
import com.afollestad.nocknock.data.SiteSettingsDao
import com.afollestad.nocknock.data.ValidationResultsDao
import com.afollestad.nocknock.data.model.Header
import com.afollestad.nocknock.data.model.RetryPolicy
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.SiteSettings
import com.afollestad.nocknock.data.model.Status
import com.afollestad.nocknock.data.model.Status.OK
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE
import com.afollestad.nocknock.data.model.ValidationResult
import com.afollestad.nocknock.utilities.providers.CanNotifyModel
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.isA
import com.nhaarman.mockitokotlin2.mock
import java.lang.System.currentTimeMillis
fun fakeIntent(action: String): Intent {
return mock {
on { getAction() } doReturn action
}
}
fun fakeSettingsModel(
id: Long,
validationMode: ValidationMode = STATUS_CODE
) = SiteSettings(
siteId = id,
validationIntervalMs = 600000,
validationMode = validationMode,
validationArgs = null,
disabled = false,
networkTimeout = 10000,
certificate = null
)
fun fakeResultModel(
id: Long,
status: Status = OK,
reason: String? = null
) = ValidationResult(
siteId = id,
status = status,
reason = reason,
timestampMs = currentTimeMillis()
)
fun fakeRetryPolicy(
id: Long,
count: Int = 3,
minutes: Int = 6
) = RetryPolicy(
siteId = id,
count = count,
minutes = minutes
)
fun fakeHeaders(siteId: Long): List<Header> {
return listOf(
Header(id = siteId + 1, siteId = siteId, key = "Content-Type", value = "text/html"),
Header(id = siteId + 2, siteId = siteId, key = "User-Agent", value = "NockNock")
)
}
fun fakeModel(
id: Long,
tags: String = ""
) = Site(
id = id,
name = "Test",
url = "https://test.com",
tags = tags,
settings = fakeSettingsModel(id),
lastResult = fakeResultModel(id),
retryPolicy = fakeRetryPolicy(id),
headers = fakeHeaders(id)
)
val MOCK_MODEL_1 = fakeModel(1, tags = "one,two")
val MOCK_MODEL_2 = fakeModel(2, tags = "three,four")
val MOCK_MODEL_3 = fakeModel(3, tags = "five,six")
val ALL_MOCK_MODELS = listOf(MOCK_MODEL_1, MOCK_MODEL_2, MOCK_MODEL_3)
fun mockDatabase(): AppDatabase {
val siteDao = mock<SiteDao> {
on { insert(isA()) } doReturn 1
on { one(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1)
2L -> listOf(MOCK_MODEL_2)
3L -> listOf(MOCK_MODEL_3)
else -> listOf()
}
}
on { all() } doReturn ALL_MOCK_MODELS
on { update(isA()) } doAnswer { inv ->
return@doAnswer inv.arguments.size
}
on { delete(isA()) } doAnswer { inv ->
return@doAnswer inv.arguments.size
}
}
val settingsDao = mock<SiteSettingsDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.settings!!)
2L -> listOf(MOCK_MODEL_2.settings!!)
3L -> listOf(MOCK_MODEL_3.settings!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val resultsDao = mock<ValidationResultsDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.lastResult!!)
2L -> listOf(MOCK_MODEL_2.lastResult!!)
3L -> listOf(MOCK_MODEL_3.lastResult!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val retryDao = mock<RetryPolicyDao> {
on { insert(isA()) } doReturn 1L
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> listOf(MOCK_MODEL_1.retryPolicy!!)
2L -> listOf(MOCK_MODEL_2.retryPolicy!!)
3L -> listOf(MOCK_MODEL_3.retryPolicy!!)
else -> listOf()
}
}
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
val headerDao = mock<HeaderDao> {
on { all() } doReturn MOCK_MODEL_1.headers + MOCK_MODEL_2.headers + MOCK_MODEL_3.headers
on { forSite(isA()) } doAnswer { inv ->
val id = inv.getArgument<Long>(0)
return@doAnswer when (id) {
1L -> MOCK_MODEL_1.headers
2L -> MOCK_MODEL_2.headers
3L -> MOCK_MODEL_3.headers
else -> listOf()
}
}
on { insert(isA<Header>()) } doReturn 1L
on { insert(isA<List<Header>>()) } doReturn listOf(1L, 2L)
on { update(isA()) } doReturn 1
on { delete(isA()) } doReturn 1
}
return mock {
on { siteDao() } doReturn siteDao
on { siteSettingsDao() } doReturn settingsDao
on { validationResultsDao() } doReturn resultsDao
on { retryPolicyDao() } doReturn retryDao
on { headerDao() } doReturn headerDao
}
}
fun mockIntentProvider() = object : IntentProvider {
override fun createFilter(vararg actions: String): IntentFilter {
return mock {
on { this.getAction(any()) } doAnswer { inv ->
val index = inv.getArgument<Int>(0)
return@doAnswer actions[index]
}
on { this.actionsIterator() } doReturn actions.iterator()
on { this.countActions() } doReturn actions.size
}
}
override fun getPendingIntentForViewSite(model: CanNotifyModel): PendingIntent {
// basically no-op right now
return mock()
}
}

View file

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

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