Compare commits

..

156 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
Aidan Follestad
cf39207c08 Version 0.8.0 2018-12-01 16:01:45 -08:00
Aidan Follestad
08ddf1ca03 Bug fixes, UI improvements, fix notifications, etc. 2018-12-01 15:58:24 -08:00
Aidan Follestad
b750957d79 Change add site button text 2018-12-01 12:44:33 -08:00
Aidan Follestad
00973b9c71 Restore view site activity slide-up animation 2018-12-01 12:42:37 -08:00
Aidan Follestad
c6307f5061 Main list shows 'Now' as the next check time if the status is pending 2018-12-01 12:33:57 -08:00
Aidan Follestad
2d81575e4b Move majority of view site logic to a presenter, add unit tests 2018-12-01 12:31:16 -08:00
Aidan Follestad
cf802dfa2f Docs cleanup 2018-12-01 01:31:46 -08:00
Aidan Follestad
225434b41a Move majority of add site logic to a presenter, add unit tests 2018-12-01 01:30:25 -08:00
Aidan Follestad
f87e1438d2 Move majority of MainActivity business logic to MainPresenter, write unit test 2018-12-01 00:05:14 -08:00
Aidan Follestad
b36b41ca9d Fix long press functions in the main list 2018-11-30 22:58:53 -08:00
Aidan Follestad
cfe9df9225 Theme Material Dialogs 2018-11-30 22:55:07 -08:00
Aidan Follestad
62ce516972 Use styles to keep view styles consistent throughout the app 2018-11-30 22:52:35 -08:00
Aidan Follestad
1a66d2bbd7 Tweaks to list item layout of the main screen 2018-11-30 22:22:13 -08:00
Aidan Follestad
8193dd017d The ability to disable checks for sites, resolves #8. 2018-11-30 22:19:22 -08:00
Aidan Follestad
ef73245831 WIP use custom fonts, cleanup layouts, etc. 2018-11-30 18:28:09 -08:00
Aidan Follestad
77a98b161b Limit interval input field to 6 characters, resolves #17. 2018-11-30 15:48:03 -08:00
Aidan Follestad
6dfff5bb12 Begin to modularize reused view layouts, switch to Material Components theme 2018-11-30 15:34:40 -08:00
Aidan Follestad
c7096e8746 Don't save changes to existing site if validation doesn't pass. Resolves #22. 2018-11-30 14:28:58 -08:00
Aidan Follestad
a8bcc60496 Debounce clicks on list items in the main screen 2018-11-30 14:25:52 -08:00
Aidan Follestad
3507aec3db Don't need to showcase F-Droid. 2018-11-30 14:03:35 -08:00
Aidan Follestad
7e500fc1ed Add issue and pull request templates 2018-11-30 13:59:59 -08:00
Aidan Follestad
999502802a Manage 'app is open' state from the Application class 2018-11-30 13:54:01 -08:00
Aidan Follestad
eeaa68dbe2 Ensure we have scheduled jobs when the system boots or when the app starts. 2018-11-30 12:27:07 -08:00
Aidan Follestad
d1672a6c5e Create notification channels when app is startwd, add logging to notification manager 2018-11-30 12:01:08 -08:00
Aidan Follestad
0785c36b2a Move script validation mode UI into included layout, resolve lint error with Rhino by ignoring it, etc. 2018-11-30 11:35:43 -08:00
Aidan Follestad
c7a8148d3c Various fixes and behavioral tweaks 2018-11-30 10:45:22 -08:00
Aidan Follestad
56eb67d825 Update all deps, re-write everything in Kotlin, use Dagger, etc. 2018-11-29 23:43:28 -08:00
Aidan Follestad
8a6cb18ae6 Library usage updates 2018-01-05 15:05:46 -06:00
Aidan Follestad
bc6a7bb559 Dependency, gradle, etc. updates 2018-01-05 14:47:29 -06:00
Aidan Follestad
c711ca9e57 Added F-Droid badge 2017-10-02 12:51:05 -05:00
anoy
dfb01f0304 added F-Droid badge
refs #6
2017-10-02 10:00:21 +02:00
Aidan Follestad
8ec5280a01 Added spotless plugin 2017-06-10 12:30:15 -05:00
Aidan Follestad
ac36b94233 Merge pull request #5 from arose13/master
proper way to reference intent actions
2017-03-05 13:31:15 -06:00
Anthony
32b17ed5d3 proper way to reference intent actions 2017-03-04 21:42:24 -05:00
Aidan Follestad
beece8c0c1 0.1.3.1 2016-09-02 15:08:23 -05:00
Aidan Follestad
680f6b2931 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	app/build.gradle
2016-09-02 15:05:06 -05:00
Aidan Follestad
b32880ca9d 0.1.2.3 2016-09-02 15:04:24 -05:00
Aidan Follestad
22b6501745 Build Tools 24.0.1 2016-08-24 20:29:01 -05:00
Aidan Follestad
084db24a2e 0.1.3.0 2016-08-24 20:28:04 -05:00
Aidan Follestad
c44e7779b0 Updated the APK 2016-08-24 20:05:24 -05:00
Aidan Follestad
ec76b96a44 Switched the service to an IntentService so checks are queued 2016-08-24 20:01:55 -05:00
Aidan Follestad
5e1253b13d Google lib updates 2016-08-17 13:55:22 -05:00
263 changed files with 11375 additions and 2953 deletions

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.

8
.github/pull_request_template.md vendored Normal file
View file

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

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

1
.idea/.name generated
View file

@ -1 +0,0 @@
Nock Nock

22
.idea/compiler.xml generated
View file

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

View file

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

6
.idea/encodings.xml generated
View file

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

39
.idea/misc.xml generated
View file

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

9
.idea/modules.xml generated
View file

@ -2,8 +2,13 @@
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/NockNock.iml" filepath="$PROJECT_DIR$/NockNock.iml" />
<module fileurl="file://$PROJECT_DIR$/app/app.iml" filepath="$PROJECT_DIR$/app/app.iml" />
<module fileurl="file://$PROJECT_DIR$/common/common.iml" filepath="$PROJECT_DIR$/common/common.iml" />
<module fileurl="file://$PROJECT_DIR$/data/data.iml" filepath="$PROJECT_DIR$/data/data.iml" />
<module fileurl="file://$PROJECT_DIR$/engine/engine.iml" filepath="$PROJECT_DIR$/engine/engine.iml" />
<module fileurl="file://$PROJECT_DIR$/nock-nock.iml" filepath="$PROJECT_DIR$/nock-nock.iml" />
<module fileurl="file://$PROJECT_DIR$/notifications/notifications.iml" filepath="$PROJECT_DIR$/notifications/notifications.iml" />
<module fileurl="file://$PROJECT_DIR$/viewcomponents/viewcomponents.iml" filepath="$PROJECT_DIR$/viewcomponents/viewcomponents.iml" />
</modules>
</component>
</project>
</project>

View file

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

Binary file not shown.

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

@ -1,43 +1,79 @@
apply from: '../dependencies.gradle'
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 24
buildToolsVersion "24.0.0"
compileSdkVersion versions.compileSdk
buildToolsVersion versions.buildTools
defaultConfig {
applicationId "com.afollestad.nocknock"
minSdkVersion 21
targetSdkVersion 24
versionCode 12
versionName "0.1.2.2"
defaultConfig {
applicationId "com.afollestad.nocknock"
minSdkVersion versions.minSdk
targetSdkVersion versions.compileSdk
versionCode versions.publishVersionCode
versionName versions.publishVersion
}
lintOptions {
abortOnError false
}
jackOptions {
enabled true
}
}
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
}
dependencies {
compile 'com.android.support:appcompat-v7:24.1.1'
compile 'com.afollestad.material-dialogs:core:0.9.0.0'
compile 'com.afollestad.material-dialogs:commons:0.9.0.0'
compile 'com.afollestad:bridge:3.2.5'
compile 'com.afollestad:inquiry:3.2.1'
compile 'com.android.support:design:24.1.1'
compile files('libs/rhino-1.7.7.1.jar')
}
implementation project(':common')
implementation project(':engine')
implementation project(':data')
implementation project(':notifications')
implementation project(':viewcomponents')
// Google/AppCompat
implementation 'androidx.appcompat:appcompat:' + versions.androidxCore
implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView
implementation 'com.google.android.material:material:' + versions.googleMaterial
implementation 'androidx.browser:browser:' + versions.androidxBrowser
implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore
// Lifecycle
kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle
// Kotlin
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin
// JOIN
implementation 'org.koin:koin-android:' + versions.koin
implementation 'org.koin:koin-androidx-scope:' + versions.koin
implementation 'org.koin:koin-androidx-viewmodel:' + versions.koin
// afollestad
implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
// Debugging
implementation 'com.jakewharton.timber:timber:' + versions.timber
implementation("com.crashlytics.sdk.android:crashlytics:${versions.fabric}") {
transitive = true
}
// Testing
testImplementation 'junit:junit:' + versions.junit
testImplementation 'org.mockito:mockito-core:' + versions.mockito
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin
testImplementation 'com.google.truth:truth:' + versions.truth
testImplementation 'androidx.arch.core:core-testing:' + versions.archTesting
// UI testing
androidTestImplementation 'androidx.test:runner:' + versions.androidxTestRunner
androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner
}
apply from: '../spotless.gradle'
apply from: '../mock/mock.gradle'
apply plugin: "io.fabric"
apply plugin: 'com.google.gms.google-services'

Binary file not shown.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,79 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("unused")
package com.afollestad.nocknock
import android.app.Application
import com.afollestad.nocknock.BuildConfig.DEBUG
import com.afollestad.nocknock.engine.engineModule
import com.afollestad.nocknock.koin.mainModule
import com.afollestad.nocknock.koin.prefModule
import com.afollestad.nocknock.koin.viewModelModule
import com.afollestad.nocknock.logging.FabricTree
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.notifications.notificationsModule
import com.afollestad.nocknock.utilities.commonModule
import com.crashlytics.android.Crashlytics
import io.fabric.sdk.android.Fabric
import org.koin.android.ext.android.inject
import org.koin.android.ext.android.startKoin
import timber.log.Timber
import timber.log.Timber.DebugTree
import timber.log.Timber.d as log
/** @author Aidan Follestad (@afollestad) */
class NockNockApp : Application() {
private var resumedActivities: Int = 0
override fun onCreate() {
super.onCreate()
if (DEBUG) {
Timber.plant(DebugTree())
}
Timber.plant(FabricTree())
Fabric.with(this, Crashlytics())
val modules = listOf(
prefModule,
mainModule,
engineModule,
commonModule,
notificationsModule,
viewModelModule
)
startKoin(
androidContext = this,
modules = modules
)
val nockNotificationManager by inject<NockNotificationManager>()
onActivityLifeChange { activity, resumed ->
if (resumed) {
resumedActivities++
log("Activity resumed: $activity, resumedActivities = $resumedActivities")
} else {
resumedActivities--
log("Activity paused: $activity, resumedActivities = $resumedActivities")
}
check(resumedActivities >= 0) { "resumedActivities can't go below 0." }
nockNotificationManager.setIsAppOpen(resumedActivities > 0)
}
}
}

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

@ -0,0 +1,44 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.dialogs
import android.app.Dialog
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.BuildConfig
import com.afollestad.nocknock.R
/** @author Aidan Follestad (@afollestad) */
class AboutDialog : DialogFragment() {
companion object {
private const val TAG = "[ABOUT_DIALOG]"
fun show(context: AppCompatActivity) {
val dialog = AboutDialog()
dialog.show(context.supportFragmentManager, TAG)
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val context = activity ?: throw IllegalStateException("Oh no!")
return MaterialDialog(context)
.title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME))
.positiveButton(R.string.dismiss)
.message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f)
}
}

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,235 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.addsite
import android.annotation.SuppressLint
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.lifecycle.Observer
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.ui.viewsite.KEY_SITE
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection
import com.afollestad.nocknock.utilities.livedata.distinct
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.headersLayout
import kotlinx.android.synthetic.main.activity_addsite.inputName
import kotlinx.android.synthetic.main.activity_addsite.inputTags
import kotlinx.android.synthetic.main.activity_addsite.inputUrl
import kotlinx.android.synthetic.main.activity_addsite.loadingProgress
import kotlinx.android.synthetic.main.activity_addsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_addsite.scrollView
import kotlinx.android.synthetic.main.activity_addsite.sslCertificateBrowse
import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput
import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription
import kotlinx.android.synthetic.main.include_app_bar.toolbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
/** @author Aidan Follestad (@afollestad) */
class AddSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
private val viewModel by viewModel<AddSiteViewModel>()
private lateinit var validationForm: Form
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_addsite)
setupUi()
setupValidation()
lifecycle.addObserver(viewModel)
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as? Site
model?.let { viewModel.prePopulateFromModel(model) }
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
// Name
inputName.attachLiveData(this, viewModel.name)
// Tags
inputTags.attachLiveData(this, viewModel.tags)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
// Validation mode
responseValidationMode.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationMode,
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
// Validation search term
responseValidationSearchTerm.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationSearchTerm,
pullInChanges = false
)
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// SSL certificate
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
viewModel.certificateUri.distinct()
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
// Headers
headersLayout.attach(viewModel.headers)
}
private fun setupUi() {
toolbarTitle.setText(R.string.add_site)
toolbar.run {
inflateMenu(R.menu.menu_addsite)
setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() }
}
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
resources.getStringArray(R.array.response_validation_options)
)
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
// SSL certificate
sslCertificateBrowse.setOnClickListener {
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
addCategory(CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
}
}
private fun setupValidation() {
validationForm = form {
input(inputName, name = "Name") {
isNotEmpty().description(R.string.please_enter_name)
}
input(inputUrl, name = "URL") {
isNotEmpty().description(R.string.please_enter_url)
isUrl().description(R.string.please_enter_valid_url)
}
input(responseTimeoutInput, name = "Timeout", optional = true) {
isNumber().greaterThan(0)
.description(R.string.please_enter_networkTimeout)
}
input(responseValidationSearchTerm, name = "Search term") {
conditional(responseValidationSearchTerm.isVisibleCondition()) {
isNotEmpty().description(R.string.please_enter_search_term)
}
}
input(sslCertificateInput, name = "Certificate Path", optional = true) {
isUri().hasScheme("file", "content")
.that { it.host != null }
.description(R.string.please_enter_validCertUri)
}
submitWith(toolbar.menu, R.id.commit) {
viewModel.commit {
setResult(RESULT_OK)
finish()
}
}
}
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
visibility = viewModel.onValidationScriptVisibility(),
form = validationForm
)
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
form = validationForm
)
// Retry Policy
retryPolicyLayout.attach(
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes,
form = validationForm
)
}
override fun onResume() {
super.onResume()
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
}
}
}

View file

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

@ -0,0 +1,149 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.main
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.list.listItems
import com.afollestad.nocknock.R
import com.afollestad.nocknock.adapter.SiteAdapter
import com.afollestad.nocknock.adapter.TagAdapter
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.dialogs.AboutDialog
import com.afollestad.nocknock.notifications.NockNotificationManager
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.ui.NightMode.UNKNOWN
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import kotlinx.android.synthetic.main.activity_main.fab
import kotlinx.android.synthetic.main.activity_main.list
import kotlinx.android.synthetic.main.activity_main.loadingProgress
import kotlinx.android.synthetic.main.include_app_bar.toolbar
import kotlinx.android.synthetic.main.include_empty_view.emptyText
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlinx.android.synthetic.main.activity_main.tags_list as tagsList
/** @author Aidan Follestad (@afollestad) */
class MainActivity : DarkModeSwitchActivity() {
private val notificationManager by inject<NockNotificationManager>()
private val intentProvider by inject<IntentProvider>()
internal val viewModel by viewModel<MainViewModel>()
private lateinit var siteAdapter: SiteAdapter
private lateinit var tagAdapter: TagAdapter
private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) {
viewModel.postSiteUpdate(it)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupUi()
notificationManager.createChannels()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
viewModel.onSites()
.observe(this, Observer { siteAdapter.set(it) })
viewModel.onEmptyTextVisibility()
.toViewVisibility(this, emptyText)
viewModel.onTags()
.observe(this, Observer { tagAdapter.set(it) })
viewModel.onTagsListVisibility()
.toViewVisibility(this, tagsList)
loadingProgress.observe(this, viewModel.onIsLoading())
processIntent(intent)
}
private fun setupUi() {
toolbar.run {
inflateMenu(R.menu.menu_main)
menu.findItem(R.id.dark_mode)
.apply {
if (getCurrentNightMode() == UNKNOWN) {
isChecked = isDarkMode()
} else {
isVisible = false
}
}
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.about -> AboutDialog.show(this@MainActivity)
R.id.dark_mode -> toggleDarkMode()
}
return@setOnMenuItemClickListener true
}
}
siteAdapter = SiteAdapter(this::onSiteSelected)
list.run {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = siteAdapter
addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL))
}
tagAdapter = TagAdapter(viewModel::onTagSelection)
tagsList.run {
layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false)
adapter = tagAdapter
}
fab.setOnClickListener { addSite() }
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.let(::processIntent)
}
private fun onSiteSelected(
model: Site,
longClick: Boolean
) {
if (longClick) {
MaterialDialog(this).show {
title(R.string.options)
listItems(R.array.site_long_options) { _, i, _ ->
when (i) {
0 -> viewModel.refreshSite(model)
1 -> addSiteForDuplication(model)
2 -> maybeRemoveSite(model)
}
}
}
} else {
viewSite(model)
}
}
}

View file

@ -0,0 +1,73 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.main
import android.content.Intent
import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.nocknock.R
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.toHtml
import com.afollestad.nocknock.ui.addsite.AddSiteActivity
import com.afollestad.nocknock.ui.viewsite.KEY_SITE
import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity
import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL
internal const val VIEW_SITE_RQ = 6923
internal const val ADD_SITE_RQ = 6969
// ADD
internal fun MainActivity.addSite() {
startActivityForResult(intentToAdd(), ADD_SITE_RQ)
}
internal fun MainActivity.addSiteForDuplication(site: Site) {
startActivityForResult(intentToAdd(site), ADD_SITE_RQ)
}
private fun MainActivity.intentToAdd(model: Site? = null) =
Intent(this, AddSiteActivity::class.java).apply {
model?.let { putExtra(KEY_SITE, it) }
}
// VIEW
internal fun MainActivity.viewSite(model: Site) {
startActivityForResult(intentToView(model), VIEW_SITE_RQ)
}
private fun MainActivity.intentToView(model: Site) =
Intent(this, ViewSiteActivity::class.java).apply {
putExtra(KEY_SITE, model)
}
// MISC
internal fun MainActivity.maybeRemoveSite(model: Site) {
MaterialDialog(this).show {
title(R.string.remove_site)
message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml())
positiveButton(R.string.remove) { viewModel.removeSite(model) }
negativeButton(android.R.string.cancel)
}
}
internal fun MainActivity.processIntent(intent: Intent) {
if (intent.hasExtra(KEY_VIEW_NOTIFICATION_MODEL)) {
val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as Site
viewSite(model)
}
}

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

@ -0,0 +1,291 @@
/**
* Designed and developed by Aidan Follestad (@afollestad)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.afollestad.nocknock.ui.viewsite
import android.annotation.SuppressLint
import android.content.Intent
import android.content.Intent.ACTION_OPEN_DOCUMENT
import android.content.Intent.CATEGORY_OPENABLE
import android.os.Bundle
import android.widget.ArrayAdapter
import androidx.lifecycle.Observer
import com.afollestad.nocknock.R
import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver
import com.afollestad.nocknock.data.model.Site
import com.afollestad.nocknock.data.model.ValidationMode
import com.afollestad.nocknock.ui.DarkModeSwitchActivity
import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection
import com.afollestad.nocknock.utilities.livedata.distinct
import com.afollestad.nocknock.utilities.providers.IntentProvider
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_viewsite.headersLayout
import kotlinx.android.synthetic.main.activity_viewsite.iconStatus
import kotlinx.android.synthetic.main.activity_viewsite.inputName
import kotlinx.android.synthetic.main.activity_viewsite.inputTags
import kotlinx.android.synthetic.main.activity_viewsite.inputUrl
import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress
import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode
import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm
import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout
import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout
import kotlinx.android.synthetic.main.activity_viewsite.scrollView
import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateBrowse
import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateInput
import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult
import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck
import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning
import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription
import kotlinx.android.synthetic.main.include_app_bar.toolbar
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar
import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle
/** @author Aidan Follestad (@afollestad) */
class ViewSiteActivity : DarkModeSwitchActivity() {
companion object {
private const val SELECT_CERT_FILE_RQ = 23
}
internal val viewModel by viewModel<ViewSiteViewModel>()
private lateinit var validationForm: Form
private val intentProvider by inject<IntentProvider>()
private val statusUpdateReceiver by lazy {
StatusUpdateIntentReceiver(application, intentProvider) {
viewModel.setModel(it)
}
}
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_viewsite)
// Populate view model with initial data
val model = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(model)
setupUi()
setupValidation()
lifecycle.run {
addObserver(viewModel)
addObserver(statusUpdateReceiver)
}
// Loading
loadingProgress.observe(this, viewModel.onIsLoading())
// Status
viewModel.status.observe(this, Observer {
iconStatus.setStatus(it)
invalidateMenuForStatus(it)
})
// Name
inputName.attachLiveData(this, viewModel.name)
// Tags
inputTags.attachLiveData(this, viewModel.tags)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
// Validation mode
responseValidationMode.attachLiveData(
lifecycleOwner = this,
data = viewModel.validationMode,
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
// Validation search term
responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm)
viewModel.onValidationSearchTermVisibility()
.toViewVisibility(this, responseValidationSearchTerm)
// SSL certificate
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
viewModel.certificateUri.distinct()
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
// Headers
headersLayout.attach(viewModel.headers)
// Last/next check
viewModel.onLastCheckResultText()
.toViewText(this, textLastCheckResult)
viewModel.onNextCheckText()
.toViewText(this, textNextCheck)
}
private fun setupUi() {
toolbarTitle.text = ""
toolbar.run {
setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() }
inflateMenu(R.menu.menu_viewsite)
menu.findItem(R.id.refresh)
.setActionView(R.layout.menu_item_refresh_icon)
.apply {
actionView.setOnClickListener { viewModel.checkNow() }
}
setOnMenuItemClickListener {
when (it.itemId) {
R.id.remove -> maybeRemoveSite()
R.id.disableChecks -> maybeDisableChecks()
}
true
}
}
scrollView.onScroll {
appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
val validationOptionsAdapter = ArrayAdapter(
this,
R.layout.list_item_spinner,
resources.getStringArray(R.array.response_validation_options)
)
validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown)
responseValidationMode.adapter = validationOptionsAdapter
// Disabled button
viewModel.onDisableChecksVisibility()
.observe(this, Observer {
toolbar.menu.findItem(R.id.disableChecks)
.isVisible = it
})
// Done item text
viewModel.onDoneButtonText()
.observe(this, Observer {
toolbar.menu.findItem(R.id.commit)
.setTitle(it)
})
// SSL certificate
sslCertificateBrowse.setOnClickListener {
val intent = Intent(ACTION_OPEN_DOCUMENT).apply {
addCategory(CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, SELECT_CERT_FILE_RQ)
}
}
private fun setupValidation() {
validationForm = form {
input(inputName, name = "Name") {
isNotEmpty().description(R.string.please_enter_name)
}
input(inputUrl, name = "URL") {
isNotEmpty().description(R.string.please_enter_url)
isUrl().description(R.string.please_enter_valid_url)
}
input(responseValidationSearchTerm, name = "Search term") {
conditional(responseValidationSearchTerm.isVisibleCondition()) {
isNotEmpty().description(R.string.please_enter_search_term)
}
}
input(responseTimeoutInput, name = "Timeout", optional = true) {
isNumber().greaterThan(0)
.description(R.string.please_enter_networkTimeout)
}
input(sslCertificateInput, name = "Certificate Path", optional = true) {
isUri().hasScheme("file", "content")
.that { it.host != null }
.description(R.string.please_enter_validCertUri)
}
submitWith(toolbar.menu, R.id.commit) {
viewModel.commit { finish() }
}
}
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
visibility = viewModel.onValidationScriptVisibility(),
form = validationForm
)
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
form = validationForm
)
// Retry Policy
retryPolicyLayout.attach(
timesData = viewModel.retryPolicyTimes,
minutesData = viewModel.retryPolicyMinutes,
form = validationForm
)
}
override fun onResume() {
super.onResume()
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {
appToolbar.dimenFloat(R.dimen.default_elevation)
} else {
0f
}
}
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
resultData: Intent?
) {
super.onActivityResult(requestCode, resultCode, resultData)
if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) {
sslCertificateInput.setText(resultData?.data?.toString() ?: "")
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent != null && intent.hasExtra(KEY_SITE)) {
val newModel = intent.getSerializableExtra(KEY_SITE) as Site
viewModel.setModel(newModel)
}
}
}

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,104 +0,0 @@
package com.afollestad.nocknock.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.View;
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
public class DividerItemDecoration extends RecyclerView.ItemDecoration {
private static final int[] ATTRS = new int[]{
android.R.attr.listDivider
};
public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;
public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;
private Drawable mDivider;
private int mOrientation;
public DividerItemDecoration(Context context, int orientation) {
final TypedArray a = context.obtainStyledAttributes(ATTRS);
mDivider = a.getDrawable(0);
a.recycle();
setOrientation(orientation);
}
public void setOrientation(int orientation) {
if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
throw new IllegalArgumentException("invalid orientation");
}
mOrientation = orientation;
}
@Override
public void onDraw(Canvas c, RecyclerView parent) {
if (mOrientation == VERTICAL_LIST) {
drawVertical(c, parent);
} else {
drawHorizontal(c, parent);
}
}
public void drawVertical(Canvas c, RecyclerView parent) {
final int left = parent.getPaddingLeft();
final int right = parent.getWidth() - parent.getPaddingRight();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int top = child.getBottom() + params.bottomMargin;
final int bottom = top + mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
}
}
public void drawHorizontal(Canvas c, RecyclerView parent) {
final int top = parent.getPaddingTop();
final int bottom = parent.getHeight() - parent.getPaddingBottom();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int left = child.getRight() + params.rightMargin;
final int right = left + mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
}
}
@Override
public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
if (mOrientation == VERTICAL_LIST) {
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
}

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

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

View file

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

View file

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#fff"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
<path
android:fillColor="?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

@ -3,7 +3,7 @@
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:fillColor="#fff"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" />
</vector>
<path
android:fillColor="?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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/about"
android:title="@string/about" />
</menu>
<item
android:id="@+id/about"
android:title="@string/about"/>
<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/refresh"
android:icon="@drawable/ic_action_refresh"
android:title="@string/refresh_status"
app:showAsAction="ifRoom" />
<item
android:id="@+id/remove"
android:icon="@drawable/ic_action_delete"
android:title="@string/remove_site"
app:showAsAction="ifRoom" />
</menu>
<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>

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