diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index a574b92..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ -(`[x]` becomes a filled in checkbox, `[ ]` is an empty one) - -- [ ] I have verified there are [no duplicate active or recent bugs, questions, or requests](https://github.com/afollestad/nock-nock/issues?q=is%3Aissue+is%3Aclosed) -- [ ] I have given my issue a non-generic title. - ---- - -If this is a improvement or feature request, you can remove everything below. -Also, please consider making a pull request if you are capable of contributing. - -###### Include the following: - - - Nock Nock version: `0.x.x` - - Affected device: Google Pixel 3 XL with Android 9.0 - ---- - -###### Reproduction Steps - -1. - ---- - -###### Expected Result - ---- - -###### Actual Result \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 0000000..2d7d09f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Something is crashing or not working as intended + +--- + +*Please consider making a Pull Request if you are capable of doing so.* + +**App Version:** + +x.x.x + +**Affected Device(s):** + +Google Pixel 3 XL with Android 9.0 + +**Describe the Bug:** + +A clear description of what is the bug is. + +**To Reproduce:** +1. +2. +3. + +**Expected Behavior:** + +A clear description of what you expected to happen. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md new file mode 100644 index 0000000..77310ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +*Please consider making a Pull Request if you are capable of doing so.* + +**Description what you'd like to happen:** + +A clear description if the feature or behavior you'd like implemented. + +**Describe alternatives you've considered:** + +A clear description of any alternative solutions you've considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/pull_request_template.md similarity index 78% rename from .github/PULL_REQUEST_TEMPLATE.md rename to .github/pull_request_template.md index b4035a9..6307e10 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/pull_request_template.md @@ -1,9 +1,8 @@ - ### Guidelines -1. You must run the `spotlessApply` task before commiting, either through Android Studio or with `./gradlew spotlessApply`. +1. You must run the `spotlessApply` task before committing, either through Android Studio or with `./gradlew spotlessApply`. 2. A PR should be focused and contained. If you are changing multiple unrelated things, they should be in separate PRs. 3. A PR should fix a bug or solve a problem - something that only you would use is not necessarily something that should be published. 4. Give your PR a detailed title and description - look over your code one last time before actually creating the PR. Give it a self-review. -**If you do not follow the guidelines, your PR will be rejected.** \ No newline at end of file +**If you do not follow the guidelines, your PR will be rejected.** diff --git a/.gitignore b/.gitignore index 161128f..454e51a 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,6 @@ gradle-app.setting .gradletasknamecache # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 -# gradle/wrapper/gradle-wrapper.properties \ No newline at end of file +# gradle/wrapper/gradle-wrapper.properties + +app/google-services.json \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 06ee295..50f0406 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,11 +1,16 @@ + + + + + - + diff --git a/.idea/modules.xml b/.idea/modules.xml index d5f1277..23f5d23 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -3,11 +3,11 @@ + - diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c1eda38..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: android -jdk: oraclejdk8 -android: - components: - - tools - - platform-tools - - build-tools-28.0.3 - - android-28 - - extra-android-support - - extra-android-m2repository - - extra-google-m2repository - - # Additional components - #- extra-google-google_play_services - #- addon-google_apis-google-19 - - # Specify at least one system image, if you need to run emulator(s) during your tests - #- sys-img-armeabi-v7a-android-19 - #- sys-img-x86-android-17 - - licenses: - - '.+' diff --git a/README.md b/README.md index ea087de..84ee16c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ ## Nock Nock -[![Build Status](https://travis-ci.org/afollestad/nock-nock.svg)](https://travis-ci.org/afollestad/nock-nock) [![License](https://img.shields.io/badge/license-Apache%202-4EB1BA.svg?style=flat-square)](https://www.apache.org/licenses/LICENSE-2.0.html) -![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcasemain.png) +![Showcase](https://raw.githubusercontent.com/afollestad/nock-nock/master/art/showcase5.png) Nock Nock is a simple app which allows you to monitor your websites for maximum uptime. @@ -11,4 +10,4 @@ The app will automatically knock on the door of your websites (or web servers) o to make sure they are up and responding successfully. If something is wrong, you get a notification telling you so.
-Get it on Google Play \ No newline at end of file +Get it on Google Play diff --git a/app/build.gradle b/app/build.gradle index d9184e1..d6b315e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,30 +15,65 @@ android { versionCode versions.publishVersionCode versionName versions.publishVersion } + + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } + + packagingOptions { + exclude 'META-INF/atomicfu.kotlin_module' + } } dependencies { - implementation project(':data') - implementation project(':utilities') + implementation project(':common') implementation project(':engine') + implementation project(':data') implementation project(':notifications') implementation project(':viewcomponents') - implementation 'androidx.appcompat:appcompat:' + versions.androidx - implementation 'androidx.recyclerview:recyclerview:' + versions.androidx - implementation 'com.google.android.material:material:' + versions.androidx + // Google/AppCompat + implementation 'androidx.appcompat:appcompat:' + versions.androidxCore + implementation 'androidx.recyclerview:recyclerview:' + versions.androidxRecyclerView + implementation 'com.google.android.material:material:' + versions.googleMaterial + implementation 'androidx.browser:browser:' + versions.androidxBrowser + implementation 'com.google.firebase:firebase-core:' + versions.firebaseCore + // Lifecycle + kapt 'androidx.lifecycle:lifecycle-compiler:' + versions.lifecycle + + // Kotlin implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:' + versions.kotlin - implementation 'com.google.dagger:dagger:' + versions.dagger - kapt 'com.google.dagger:dagger-compiler:' + versions.dagger + // JOIN + implementation 'org.koin:koin-android:' + versions.koin + implementation 'org.koin:koin-androidx-scope:' + versions.koin + implementation 'org.koin:koin-androidx-viewmodel:' + versions.koin + // afollestad implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs + // Debugging + implementation 'com.jakewharton.timber:timber:' + versions.timber + implementation("com.crashlytics.sdk.android:crashlytics:${versions.fabric}") { + transitive = true + } + + // Testing testImplementation 'junit:junit:' + versions.junit testImplementation 'org.mockito:mockito-core:' + versions.mockito testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:' + versions.mockitoKotlin testImplementation 'com.google.truth:truth:' + versions.truth + testImplementation 'androidx.arch.core:core-testing:' + versions.archTesting + + // UI testing + androidTestImplementation 'androidx.test:runner:' + versions.androidxTestRunner + androidTestImplementation 'androidx.test:rules:' + versions.androidxTestRunner } -apply from: '../spotless.gradle' \ No newline at end of file +apply from: '../spotless.gradle' +apply from: '../mock/mock.gradle' + +apply plugin: "io.fabric" +apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/app/release/NockNock.apk b/app/release/NockNock.apk deleted file mode 100644 index 21235a5..0000000 Binary files a/app/release/NockNock.apk and /dev/null differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 87a00d8..332578e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,22 +31,20 @@ android:name="com.afollestad.nocknock.ui.addsite.AddSiteActivity" android:label="@string/add_site" android:launchMode="singleTop" - android:theme="@style/AppTheme.Transparent" android:windowSoftInputMode="stateHidden"/> - + diff --git a/app/src/main/java/com/afollestad/nocknock/AppExt.kt b/app/src/main/java/com/afollestad/nocknock/AppExt.kt index efd36d4..828e01f 100644 --- a/app/src/main/java/com/afollestad/nocknock/AppExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/AppExt.kt @@ -1,16 +1,32 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.afollestad.nocknock import android.app.Activity import android.app.Application import android.app.Application.ActivityLifecycleCallbacks +import android.content.ActivityNotFoundException +import android.content.Intent import android.os.Bundle +import androidx.browser.customtabs.CustomTabsIntent import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.HtmlCompat.fromHtml +import com.afollestad.materialdialogs.utils.MDUtil.resolveColor +import com.afollestad.nocknock.utilities.ext.toUri +import com.afollestad.nocknock.utilities.ui.toast typealias ActivityLifeChange = (activity: Activity, resumed: Boolean) -> Unit @@ -40,3 +56,37 @@ fun Application.onActivityLifeChange(cb: ActivityLifeChange) { } fun String.toHtml() = fromHtml(this, FROM_HTML_MODE_LEGACY) + +fun Activity.viewUrl(url: String) { + val customTabsIntent = CustomTabsIntent.Builder() + .apply { + setToolbarColor(resolveColor(this@viewUrl, attr = R.attr.colorPrimary)) + } + .build() + try { + customTabsIntent.launchUrl(this, url.toUri()) + } catch (_: ActivityNotFoundException) { + toast(R.string.install_web_browser) + } +} + +fun Activity.viewUrlWithApp( + url: String, + pkg: String +) { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = url.toUri() + } + val resInfo = packageManager.queryIntentActivities(intent, 0) + for (info in resInfo) { + if (info.activityInfo.packageName.toLowerCase().contains(pkg) || + info.activityInfo.name.toLowerCase().contains(pkg) + ) { + startActivity(intent.apply { + setPackage(info.activityInfo.packageName) + }) + return + } + } + viewUrl(url) +} diff --git a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt index 5370d4b..3c29301 100644 --- a/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt +++ b/app/src/main/java/com/afollestad/nocknock/NockNockApp.kt @@ -1,62 +1,69 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ +@file:Suppress("unused") + package com.afollestad.nocknock import android.app.Application -import android.util.Log -import com.afollestad.nocknock.di.AppComponent -import com.afollestad.nocknock.di.DaggerAppComponent -import com.afollestad.nocknock.engine.statuscheck.BootReceiver -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob +import com.afollestad.nocknock.BuildConfig.DEBUG +import com.afollestad.nocknock.engine.engineModule +import com.afollestad.nocknock.koin.mainModule +import com.afollestad.nocknock.koin.prefModule +import com.afollestad.nocknock.koin.viewModelModule +import com.afollestad.nocknock.logging.FabricTree import com.afollestad.nocknock.notifications.NockNotificationManager -import com.afollestad.nocknock.ui.addsite.AddSiteActivity -import com.afollestad.nocknock.ui.main.MainActivity -import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity -import com.afollestad.nocknock.utilities.Injector -import com.afollestad.nocknock.utilities.ext.systemService -import okhttp3.OkHttpClient -import javax.inject.Inject +import com.afollestad.nocknock.notifications.notificationsModule +import com.afollestad.nocknock.utilities.commonModule +import com.crashlytics.android.Crashlytics +import io.fabric.sdk.android.Fabric +import org.koin.android.ext.android.inject +import org.koin.android.ext.android.startKoin +import timber.log.Timber +import timber.log.Timber.DebugTree +import timber.log.Timber.d as log /** @author Aidan Follestad (@afollestad) */ -class NockNockApp : Application(), Injector { - - companion object { - private fun log(message: String) { - if (BuildConfig.DEBUG) { - Log.d("NockNockApp", message) - } - } - } - - private lateinit var appComponent: AppComponent - @Inject lateinit var nockNotificationManager: NockNotificationManager +class NockNockApp : Application() { private var resumedActivities: Int = 0 override fun onCreate() { super.onCreate() - val okHttpClient = OkHttpClient.Builder() - .addNetworkInterceptor { chain -> - val request = chain.request() - .newBuilder() - .addHeader("User-Agent", "com.afollestad.nocknock") - .build() - chain.proceed(request) - } - .build() + if (DEBUG) { + Timber.plant(DebugTree()) + } - appComponent = DaggerAppComponent.builder() - .application(this) - .okHttpClient(okHttpClient) - .jobScheduler(systemService(JOB_SCHEDULER_SERVICE)) - .notificationManager(systemService(NOTIFICATION_SERVICE)) - .build() - appComponent.inject(this) + Timber.plant(FabricTree()) + Fabric.with(this, Crashlytics()) + val modules = listOf( + prefModule, + mainModule, + engineModule, + commonModule, + notificationsModule, + viewModelModule + ) + startKoin( + androidContext = this, + modules = modules + ) + + val nockNotificationManager by inject() onActivityLifeChange { activity, resumed -> if (resumed) { resumedActivities++ @@ -69,13 +76,4 @@ class NockNockApp : Application(), Injector { nockNotificationManager.setIsAppOpen(resumedActivities > 0) } } - - override fun injectInto(target: Any) = when (target) { - is MainActivity -> appComponent.inject(target) - is ViewSiteActivity -> appComponent.inject(target) - is AddSiteActivity -> appComponent.inject(target) - is CheckStatusJob -> appComponent.inject(target) - is BootReceiver -> appComponent.inject(target) - else -> throw IllegalStateException("Can't inject into $target") - } } diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt similarity index 51% rename from app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt rename to app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt index 3bf58cc..e1b034c 100644 --- a/app/src/main/java/com/afollestad/nocknock/adapter/ServerAdapter.kt +++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteAdapter.kt @@ -1,18 +1,30 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.afollestad.nocknock.adapter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil.calculateDiff import androidx.recyclerview.widget.RecyclerView import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.isPending -import com.afollestad.nocknock.data.textRes +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.Status.WAITING +import com.afollestad.nocknock.data.model.isPending +import com.afollestad.nocknock.data.model.textRes import com.afollestad.nocknock.utilities.ui.onDebouncedClick import kotlinx.android.synthetic.main.list_item_server.view.iconStatus import kotlinx.android.synthetic.main.list_item_server.view.textInterval @@ -20,12 +32,12 @@ import kotlinx.android.synthetic.main.list_item_server.view.textName import kotlinx.android.synthetic.main.list_item_server.view.textStatus import kotlinx.android.synthetic.main.list_item_server.view.textUrl -typealias Listener = (model: ServerModel, longClick: Boolean) -> Unit +typealias Listener = (model: Site, longClick: Boolean) -> Unit /** @author Aidan Follestad (@afollestad) */ -class ServerVH constructor( +class SiteViewHolder constructor( itemView: View, - private val adapter: ServerAdapter + private val adapter: SiteAdapter ) : RecyclerView.ViewHolder(itemView), View.OnLongClickListener { init { @@ -35,24 +47,32 @@ class ServerVH constructor( itemView.setOnLongClickListener(this) } - fun bind(model: ServerModel) { + fun bind(model: Site) { + requireNotNull(model.settings) { "Settings must be populated." } + itemView.textName.text = model.name itemView.textUrl.text = model.url - itemView.iconStatus.setStatus(model.status) - val statusText = model.status.textRes() - if (statusText == 0) { - itemView.textStatus.text = model.reason + val lastResult = model.lastResult + if (lastResult != null) { + itemView.iconStatus.setStatus(lastResult.status) + val statusText = lastResult.status.textRes() + if (statusText == 0) { + itemView.textStatus.text = lastResult.reason + } else { + itemView.textStatus.setText(statusText) + } } else { - itemView.textStatus.setText(statusText) + itemView.iconStatus.setStatus(WAITING) + itemView.textStatus.setText(R.string.none) } val res = itemView.resources when { - model.disabled -> { + model.settings?.disabled == true -> { itemView.textInterval.setText(R.string.checks_disabled) } - model.status.isPending() -> { + model.lastResult?.status.isPending() -> { itemView.textInterval.text = res.getString( R.string.next_check_x, res.getString(R.string.now) @@ -74,70 +94,33 @@ class ServerVH constructor( } /** @author Aidan Follestad (@afollestad) */ -class ServerAdapter(private val listener: Listener) : RecyclerView.Adapter() { +class SiteAdapter(private val listener: Listener) : RecyclerView.Adapter() { - private val models = mutableListOf() + private var models = mutableListOf() internal fun performClick( index: Int, longClick: Boolean ) = listener.invoke(models[index], longClick) - fun add(model: ServerModel) { - models.add(model) - notifyItemInserted(models.size - 1) - } - - fun update(target: ServerModel) { - for ((i, model) in models.withIndex()) { - if (model.id == target.id) { - update(i, target) - break - } - } - } - - private fun update( - index: Int, - model: ServerModel - ) { - models[index] = model - notifyItemChanged(index) - } - - fun remove(index: Int) { - models.removeAt(index) - notifyItemRemoved(index) - } - - fun remove(target: ServerModel) { - for ((i, model) in models.withIndex()) { - if (model.id == target.id) { - remove(i) - break - } - } - } - - fun set(newModels: List) { - this.models.clear() - if (!newModels.isEmpty()) { - this.models.addAll(newModels) - } - notifyDataSetChanged() + fun set(newModels: List) { + val formerModels = this.models + this.models = newModels.toMutableList() + val diffResult = calculateDiff(SiteDiffCallback(formerModels, this.models)) + diffResult.dispatchUpdatesTo(this) } override fun onCreateViewHolder( parent: ViewGroup, viewType: Int - ): ServerVH { + ): SiteViewHolder { val v = LayoutInflater.from(parent.context) .inflate(R.layout.list_item_server, parent, false) - return ServerVH(v, this) + return SiteViewHolder(v, this) } override fun onBindViewHolder( - holder: ServerVH, + holder: SiteViewHolder, position: Int ) { val model = models[position] diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt new file mode 100644 index 0000000..de8b7ca --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/adapter/SiteDiffCallback.kt @@ -0,0 +1,40 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.adapter + +import androidx.recyclerview.widget.DiffUtil +import com.afollestad.nocknock.data.model.Site + +/** @author Aidan Follestad (@afollestad) */ +class SiteDiffCallback( + private val oldItems: List, + private val newItems: List +) : DiffUtil.Callback() { + + override fun getOldListSize() = oldItems.size + + override fun getNewListSize() = newItems.size + + override fun areItemsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ) = oldItems[oldItemPosition].id == newItems[newItemPosition].id + + override fun areContentsTheSame( + oldItemPosition: Int, + newItemPosition: Int + ) = oldItems[oldItemPosition] == newItems[newItemPosition] +} diff --git a/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt new file mode 100644 index 0000000..7bf22e5 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/adapter/TagAdapter.kt @@ -0,0 +1,115 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.adapter + +import android.graphics.Color.WHITE +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnClickListener +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.afollestad.nocknock.R +import com.afollestad.nocknock.adapter.TagAdapter.TagViewHolder +import kotlinx.android.synthetic.main.list_item_tag.view.chip + +typealias TagsListener = (tags: List) -> Unit + +/** @author Aidan Follestad (@afollestad) */ +class TagAdapter( + private val listener: TagsListener +) : RecyclerView.Adapter() { + + private val tags = mutableListOf() + private val checked = mutableListOf() + + fun set(tags: List) { + this.tags.run { + clear() + addAll(tags) + } + notifyDataSetChanged() + } + + fun toggleChecked(index: Int) { + if (checked.contains(index)) { + checked.remove(index) + } else { + checked.add(index) + } + notifyItemChanged(index) + listener.invoke(getCheckedTags()) + } + + private fun getCheckedTags(): List { + return mutableListOf().apply { + checked.forEach { index -> add(tags[index]) } + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): TagViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.list_item_tag, parent, false) + return TagViewHolder(view, this) + } + + override fun getItemCount() = tags.size + + override fun onBindViewHolder( + holder: TagViewHolder, + position: Int + ) { + holder.bind(tags[position], checked.contains(position)) + } + + /** @author Aidan Follestad (@afollestad) */ + class TagViewHolder( + itemView: View, + private val adapter: TagAdapter + ) : ViewHolder(itemView), OnClickListener { + + override fun onClick(v: View) = adapter.toggleChecked(adapterPosition) + + init { + itemView.setOnClickListener(this) + } + + fun bind( + name: String, + checked: Boolean + ) = itemView.chip.run { + text = name + setTextColor( + if (checked) { + WHITE + } else { + ContextCompat.getColor(itemView.context, R.color.unchecked_chip_text) + } + ) + setBackgroundResource( + if (checked) { + R.drawable.checked_chip_selector + } else { + R.drawable.unchecked_chip_selector + } + ) + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt new file mode 100644 index 0000000..c5567c5 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/broadcasts/StatusUpdateIntentReceiver.kt @@ -0,0 +1,68 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.broadcasts + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.ACTION_STATUS_UPDATE +import com.afollestad.nocknock.engine.validation.ValidationJob.Companion.KEY_UPDATE_MODEL +import com.afollestad.nocknock.utilities.providers.IntentProvider + +typealias SiteCallback = (Site) -> Unit + +/** @author Aidan Follestad (@afollestad) */ +class StatusUpdateIntentReceiver( + private val context: Context, + private val intentProvider: IntentProvider, + private var callback: SiteCallback? +) : LifecycleObserver { + + internal val intentReceiver = object : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent + ) { + if (intent.action == ACTION_STATUS_UPDATE) { + val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? Site + ?: return + callback?.invoke(model) + } + } + } + + @OnLifecycleEvent(ON_RESUME) + fun onResume() { + val filter = intentProvider.createFilter(ACTION_STATUS_UPDATE) + context.registerReceiver(intentReceiver, filter) + } + + @OnLifecycleEvent(ON_PAUSE) + fun onPause() { + context.unregisterReceiver(intentReceiver) + } + + @OnLifecycleEvent(ON_DESTROY) + fun onDestroy() { + callback = null + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt b/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt deleted file mode 100644 index 7ae703b..0000000 --- a/app/src/main/java/com/afollestad/nocknock/di/AppComponent.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.di - -import android.app.Application -import android.app.NotificationManager -import android.app.job.JobScheduler -import com.afollestad.nocknock.NockNockApp -import com.afollestad.nocknock.engine.EngineModule -import com.afollestad.nocknock.engine.statuscheck.BootReceiver -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob -import com.afollestad.nocknock.notifications.NotificationsModule -import com.afollestad.nocknock.ui.addsite.AddSiteActivity -import com.afollestad.nocknock.ui.main.MainActivity -import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity -import com.afollestad.nocknock.utilities.UtilitiesModule -import dagger.BindsInstance -import dagger.Component -import okhttp3.OkHttpClient -import javax.inject.Singleton - -/** @author Aidan Follestad (@afollestad) */ -@Singleton -@Component( - modules = [ - MainModule::class, - MainBindModule::class, - EngineModule::class, - NotificationsModule::class, - UtilitiesModule::class - ] -) -interface AppComponent { - - fun inject(app: NockNockApp) - - fun inject(activity: MainActivity) - - fun inject(activity: ViewSiteActivity) - - fun inject(activity: AddSiteActivity) - - fun inject(job: CheckStatusJob) - - fun inject(bootReceiver: BootReceiver) - - @Component.Builder - interface Builder { - - @BindsInstance fun application(application: Application): Builder - - @BindsInstance fun okHttpClient(okHttpClient: OkHttpClient): Builder - - @BindsInstance fun jobScheduler(jobScheduler: JobScheduler): Builder - - @BindsInstance fun notificationManager(notificationManager: NotificationManager): Builder - - fun build(): AppComponent - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt b/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt deleted file mode 100644 index 31d14d2..0000000 --- a/app/src/main/java/com/afollestad/nocknock/di/MainBindModule.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.di - -import com.afollestad.nocknock.ui.addsite.AddSitePresenter -import com.afollestad.nocknock.ui.addsite.RealAddSitePresenter -import com.afollestad.nocknock.ui.main.MainPresenter -import com.afollestad.nocknock.ui.main.RealMainPresenter -import com.afollestad.nocknock.ui.viewsite.RealViewSitePresenter -import com.afollestad.nocknock.ui.viewsite.ViewSitePresenter -import dagger.Binds -import dagger.Module -import javax.inject.Singleton - -/** @author Aidan Follestad (@afollestad) */ -@Module -abstract class MainBindModule { - - @Binds - @Singleton - abstract fun provideMainPresenter( - presenter: RealMainPresenter - ): MainPresenter - - @Binds - @Singleton - abstract fun provideAddSitePresenter( - presenter: RealAddSitePresenter - ): AddSitePresenter - - @Binds - @Singleton - abstract fun provideViewSitePresenter( - presenter: RealViewSitePresenter - ): ViewSitePresenter -} diff --git a/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt deleted file mode 100644 index 6e873ca..0000000 --- a/app/src/main/java/com/afollestad/nocknock/di/MainModule.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.di - -import com.afollestad.nocknock.R -import com.afollestad.nocknock.ui.main.MainActivity -import com.afollestad.nocknock.utilities.qualifiers.AppIconRes -import com.afollestad.nocknock.utilities.qualifiers.MainActivityClass -import dagger.Module -import dagger.Provides -import javax.inject.Singleton - -/** @author Aidan Follestad (@afollestad) */ -@Module -open class MainModule { - - @Provides - @Singleton - @AppIconRes - fun provideAppIconRes(): Int = R.mipmap.ic_launcher - - @Provides - @Singleton - @MainActivityClass - fun provideMainActivityClass(): Class<*> = MainActivity::class.java -} diff --git a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt index 4c16f20..f0c152f 100644 --- a/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt +++ b/app/src/main/java/com/afollestad/nocknock/dialogs/AboutDialog.kt @@ -1,7 +1,17 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.afollestad.nocknock.dialogs @@ -10,6 +20,7 @@ import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.DialogFragment import com.afollestad.materialdialogs.MaterialDialog +import com.afollestad.nocknock.BuildConfig import com.afollestad.nocknock.R /** @author Aidan Follestad (@afollestad) */ @@ -24,8 +35,9 @@ class AboutDialog : DialogFragment() { } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return MaterialDialog(activity!!) - .title(R.string.about) + val context = activity ?: throw IllegalStateException("Oh no!") + return MaterialDialog(context) + .title(text = getString(R.string.app_name_x, BuildConfig.VERSION_NAME)) .positiveButton(R.string.dismiss) .message(R.string.about_body, html = true, lineHeightMultiplier = 1.4f) } diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt new file mode 100644 index 0000000..07f6410 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/koin/MainModule.kt @@ -0,0 +1,72 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.koin + +import android.app.Application +import android.app.NotificationManager +import android.app.job.JobScheduler +import android.content.Context.JOB_SCHEDULER_SERVICE +import android.content.Context.NOTIFICATION_SERVICE +import androidx.room.Room.databaseBuilder +import com.afollestad.nocknock.data.AppDatabase +import com.afollestad.nocknock.data.Database1to2Migration +import com.afollestad.nocknock.data.Database2to3Migration +import com.afollestad.nocknock.data.Database3to4Migration +import com.afollestad.nocknock.data.Database4to5Migration +import com.afollestad.nocknock.notifications.Qualifiers.MAIN_ACTIVITY_CLASS +import com.afollestad.nocknock.ui.main.MainActivity +import com.afollestad.nocknock.utilities.ext.systemService +import okhttp3.OkHttpClient +import org.koin.dsl.module.module + +val mainActivityCls = MainActivity::class.java + +/** @author Aidan Follestad (@afollestad) */ +val mainModule = module { + + single(name = MAIN_ACTIVITY_CLASS) { mainActivityCls } + + single { + databaseBuilder(get(), AppDatabase::class.java, "NockNock.db") + .addMigrations( + Database1to2Migration(), + Database2to3Migration(), + Database3to4Migration(), + Database4to5Migration() + ) + .build() + } + + single { + OkHttpClient.Builder() + .addNetworkInterceptor { chain -> + val request = chain.request() + .newBuilder() + .addHeader("User-Agent", "com.afollestad.nocknock") + .build() + chain.proceed(request) + } + .build() + } + + single { + get().systemService(JOB_SCHEDULER_SERVICE) + } + + single { + get().systemService(NOTIFICATION_SERVICE) + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt new file mode 100644 index 0000000..654b76d --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/koin/MainPrefModule.kt @@ -0,0 +1,32 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.koin + +import com.afollestad.rxkprefs.RxkPrefs +import com.afollestad.rxkprefs.rxkPrefs +import org.koin.dsl.module.module + +const val PREF_DARK_MODE = "dark_mode" + +/** @author Aidan Follestad (@afollestad) */ +val prefModule = module { + + single { rxkPrefs(get(), "settings") } + + factory(name = PREF_DARK_MODE) { + get().boolean(PREF_DARK_MODE, false) + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt b/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt new file mode 100644 index 0000000..ab8cd79 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/koin/ViewModelModule.kt @@ -0,0 +1,58 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.koin + +import com.afollestad.nocknock.ui.addsite.AddSiteViewModel +import com.afollestad.nocknock.ui.main.MainViewModel +import com.afollestad.nocknock.ui.viewsite.ViewSiteViewModel +import com.afollestad.nocknock.utilities.Qualifiers.IO_DISPATCHER +import com.afollestad.nocknock.utilities.Qualifiers.MAIN_DISPATCHER +import org.koin.androidx.viewmodel.ext.koin.viewModel +import org.koin.dsl.module.module + +/** @author Aidan Follestad (@afollestad) */ +val viewModelModule = module { + + viewModel { + MainViewModel( + get(), + get(), + get(), + get(name = MAIN_DISPATCHER), + get(name = IO_DISPATCHER) + ) + } + + viewModel { + AddSiteViewModel( + get(), + get(), + get(name = MAIN_DISPATCHER), + get(name = IO_DISPATCHER) + ) + } + + viewModel { + ViewSiteViewModel( + get(), + get(), + get(), + get(), + get(name = MAIN_DISPATCHER), + get(name = IO_DISPATCHER) + ) + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt b/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt new file mode 100644 index 0000000..10b97fb --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/logging/FabricTree.kt @@ -0,0 +1,37 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.logging + +import com.crashlytics.android.Crashlytics +import timber.log.Timber + +/** @author Aidan Follestad (@afollestad) */ +class FabricTree : Timber.Tree() { + + override fun log( + priority: Int, + tag: String?, + message: String, + t: Throwable? + ) { + if (t != null) { + Crashlytics.setString("crash_tag", tag) + Crashlytics.logException(t) + } else { + Crashlytics.log(priority, tag, message) + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt new file mode 100644 index 0000000..3220567 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/DarkModeSwitchActivity.kt @@ -0,0 +1,82 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui + +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.afollestad.nocknock.R +import com.afollestad.nocknock.koin.PREF_DARK_MODE +import com.afollestad.nocknock.ui.NightMode.DISABLED +import com.afollestad.nocknock.ui.NightMode.ENABLED +import com.afollestad.nocknock.ui.NightMode.UNKNOWN +import com.afollestad.nocknock.utilities.rx.attachLifecycle +import com.afollestad.rxkprefs.Pref +import org.koin.android.ext.android.inject +import timber.log.Timber.d as log + +/** @author Aidan Follestad (afollestad) */ +abstract class DarkModeSwitchActivity : AppCompatActivity() { + + private var isDarkModeEnabled: Boolean = false + private val darkModePref by inject>(name = PREF_DARK_MODE) + + override fun onCreate(savedInstanceState: Bundle?) { + isDarkModeEnabled = isDarkMode() + setTheme(themeRes()) + super.onCreate(savedInstanceState) + + if (getCurrentNightMode() == UNKNOWN) { + darkModePref.observe() + .filter { it != isDarkModeEnabled } + .subscribe { + log("Theme changed, recreating Activity.") + recreate() + } + .attachLifecycle(this) + } + } + + protected fun getCurrentNightMode(): NightMode { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return UNKNOWN + } + return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> return ENABLED + Configuration.UI_MODE_NIGHT_NO -> return DISABLED + else -> UNKNOWN + } + } + + protected fun isDarkMode(): Boolean { + return when (getCurrentNightMode()) { + ENABLED -> true + DISABLED -> false + else -> darkModePref.get() + } + } + + protected fun toggleDarkMode() = setDarkMode(!isDarkMode()) + + private fun setDarkMode(darkMode: Boolean) = darkModePref.set(darkMode) + + private fun themeRes() = if (isDarkMode()) { + R.style.AppTheme_Dark + } else { + R.style.AppTheme + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt new file mode 100644 index 0000000..2930fea --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/NightMode.kt @@ -0,0 +1,26 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui + +/** @author Aidan Follestad (@afollestad) */ +enum class NightMode { + /** Night mode is on at the system level. */ + ENABLED, + /** Night mode is off at the system level. */ + DISABLED, + /** We don't know about night mode, fallback to custom impl. */ + UNKNOWN +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt new file mode 100644 index 0000000..750173a --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/ScopedViewModel.kt @@ -0,0 +1,36 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import org.jetbrains.annotations.TestOnly + +/** @author Aidan Follestad (@afollestad) */ +abstract class ScopedViewModel(mainDispatcher: CoroutineDispatcher) : ViewModel() { + + private val job = Job() + protected val scope = CoroutineScope(job + mainDispatcher) + + override fun onCleared() { + super.onCleared() + job.cancel() + } + + @TestOnly open fun destroy() = job.cancel() +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt index 65e71c8..e15a29f 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivity.kt @@ -1,88 +1,137 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.afollestad.nocknock.ui.addsite import android.annotation.SuppressLint +import android.content.Intent +import android.content.Intent.ACTION_OPEN_DOCUMENT +import android.content.Intent.CATEGORY_OPENABLE import android.os.Bundle import android.widget.ArrayAdapter -import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ValidationMode -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.indexToValidationMode -import com.afollestad.nocknock.utilities.ext.ScopeReceiver -import com.afollestad.nocknock.utilities.ext.injector -import com.afollestad.nocknock.utilities.ext.scopeWhileAttached -import com.afollestad.nocknock.viewcomponents.ext.conceal -import com.afollestad.nocknock.viewcomponents.ext.onItemSelected -import com.afollestad.nocknock.viewcomponents.ext.onLayout -import com.afollestad.nocknock.viewcomponents.ext.showOrHide -import com.afollestad.nocknock.viewcomponents.ext.trimmedText +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.ValidationMode +import com.afollestad.nocknock.ui.DarkModeSwitchActivity +import com.afollestad.nocknock.ui.viewsite.KEY_SITE +import com.afollestad.nocknock.utilities.ext.onTextChanged +import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection +import com.afollestad.nocknock.utilities.livedata.distinct +import com.afollestad.nocknock.viewcomponents.ext.dimenFloat +import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition +import com.afollestad.nocknock.viewcomponents.ext.onScroll +import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData +import com.afollestad.nocknock.viewcomponents.livedata.toViewText +import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility +import com.afollestad.vvalidator.form +import com.afollestad.vvalidator.form.Form import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout -import kotlinx.android.synthetic.main.activity_addsite.doneBtn +import kotlinx.android.synthetic.main.activity_addsite.headersLayout import kotlinx.android.synthetic.main.activity_addsite.inputName +import kotlinx.android.synthetic.main.activity_addsite.inputTags import kotlinx.android.synthetic.main.activity_addsite.inputUrl import kotlinx.android.synthetic.main.activity_addsite.loadingProgress +import kotlinx.android.synthetic.main.activity_addsite.responseTimeoutInput import kotlinx.android.synthetic.main.activity_addsite.responseValidationMode import kotlinx.android.synthetic.main.activity_addsite.responseValidationSearchTerm -import kotlinx.android.synthetic.main.activity_addsite.rootView +import kotlinx.android.synthetic.main.activity_addsite.retryPolicyLayout import kotlinx.android.synthetic.main.activity_addsite.scriptInputLayout +import kotlinx.android.synthetic.main.activity_addsite.scrollView +import kotlinx.android.synthetic.main.activity_addsite.sslCertificateBrowse +import kotlinx.android.synthetic.main.activity_addsite.sslCertificateInput import kotlinx.android.synthetic.main.activity_addsite.textUrlWarning -import kotlinx.android.synthetic.main.activity_addsite.toolbar import kotlinx.android.synthetic.main.activity_addsite.validationModeDescription -import javax.inject.Inject -import kotlin.coroutines.CoroutineContext -import kotlin.math.max -import kotlin.properties.Delegates.notNull - -const val KEY_FAB_X = "fab_x" -const val KEY_FAB_Y = "fab_y" -const val KEY_FAB_SIZE = "fab_size" +import kotlinx.android.synthetic.main.include_app_bar.toolbar +import org.koin.androidx.viewmodel.ext.android.viewModel +import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar +import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle /** @author Aidan Follestad (@afollestad) */ -class AddSiteActivity : AppCompatActivity(), AddSiteView { +class AddSiteActivity : DarkModeSwitchActivity() { + companion object { + private const val SELECT_CERT_FILE_RQ = 23 + } - var isClosing: Boolean = false - var revealCx by notNull() - var revealCy by notNull() - var revealRadius by notNull() - - @Inject lateinit var presenter: AddSitePresenter + private val viewModel by viewModel() + private lateinit var validationForm: Form @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - injector().injectInto(this) setContentView(R.layout.activity_addsite) - presenter.takeView(this) + setupUi() + setupValidation() - toolbar.setNavigationOnClickListener { closeActivityWithReveal() } + lifecycle.addObserver(viewModel) - if (savedInstanceState == null) { - rootView.conceal() - rootView.onLayout { - val fabSize = intent.getIntExtra(KEY_FAB_SIZE, 0) - val fabX = intent.getFloatExtra(KEY_FAB_X, 0f) - .toInt() - val fabY = intent.getFloatExtra(KEY_FAB_Y, 0f) - .toInt() + // Populate view model with initial data + val model = intent.getSerializableExtra(KEY_SITE) as? Site + model?.let { viewModel.prePopulateFromModel(model) } - revealCx = fabX + fabSize / 2 - revealCy = (fabY + toolbar.measuredHeight + fabSize / 2) - revealRadius = max(revealCx, revealCy).toFloat() + // Loading + loadingProgress.observe(this, viewModel.onIsLoading()) - circularRevealActivity() - } - } + // Name + inputName.attachLiveData(this, viewModel.name) - inputUrl.setOnFocusChangeListener { _, hasFocus -> - presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText()) + // Tags + inputTags.attachLiveData(this, viewModel.tags) + + // Url + inputUrl.attachLiveData(this, viewModel.url) + viewModel.onUrlWarningVisibility() + .toViewVisibility(this, textUrlWarning) + + // Timeout + responseTimeoutInput.attachLiveData(this, viewModel.timeout) + + // Validation mode + responseValidationMode.attachLiveData( + lifecycleOwner = this, + data = viewModel.validationMode, + outTransformer = { ValidationMode.fromIndex(it) }, + inTransformer = { it.toIndex() } + ) + viewModel.onValidationModeDescription() + .toViewText(this, validationModeDescription) + + // Validation search term + responseValidationSearchTerm.attachLiveData( + lifecycleOwner = this, + data = viewModel.validationSearchTerm, + pullInChanges = false + ) + viewModel.onValidationSearchTermVisibility() + .toViewVisibility(this, responseValidationSearchTerm) + + // SSL certificate + sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } + viewModel.certificateUri.distinct() + .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) + + // Headers + headersLayout.attach(viewModel.headers) + } + + private fun setupUi() { + toolbarTitle.setText(R.string.add_site) + toolbar.run { + inflateMenu(R.menu.menu_addsite) + setNavigationIcon(R.drawable.ic_action_close) + setNavigationOnClickListener { finish() } } val validationOptionsAdapter = ArrayAdapter( @@ -91,98 +140,96 @@ class AddSiteActivity : AppCompatActivity(), AddSiteView { resources.getStringArray(R.array.response_validation_options) ) validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) - responseValidationMode.adapter = validationOptionsAdapter - responseValidationMode.onItemSelected(presenter::onValidationModeSelected) - doneBtn.setOnClickListener { - val checkInterval = checkIntervalLayout.getSelectedCheckInterval() - val validationMode = - responseValidationMode.selectedItemPosition.indexToValidationMode() + scrollView.onScroll { + appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) { + appToolbar.dimenFloat(R.dimen.default_elevation) + } else { + 0f + } + } - isClosing = true - presenter.commit( - name = inputName.trimmedText(), - url = inputUrl.trimmedText(), - checkInterval = checkInterval, - validationMode = validationMode, - validationContent = validationMode.validationContent() - ) + // SSL certificate + sslCertificateBrowse.setOnClickListener { + val intent = Intent(ACTION_OPEN_DOCUMENT).apply { + addCategory(CATEGORY_OPENABLE) + type = "*/*" + } + startActivityForResult(intent, SELECT_CERT_FILE_RQ) } } - override fun onDestroy() { - presenter.dropView() - super.onDestroy() - } - - override fun setLoading() = loadingProgress.setLoading() - - override fun setDoneLoading() = loadingProgress.setDone() - - override fun showOrHideUrlSchemeWarning(show: Boolean) { - textUrlWarning.showOrHide(show) - if (show) { - textUrlWarning.setText(R.string.warning_http_url) - } - } - - override fun showOrHideValidationSearchTerm(show: Boolean) = - responseValidationSearchTerm.showOrHide(show) - - override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show) - - override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res) - - override fun setInputErrors(errors: InputErrors) { - isClosing = false - inputName.error = if (errors.name != null) { - getString(errors.name!!) - } else { - null - } - inputUrl.error = if (errors.url != null) { - getString(errors.url!!) - } else { - null - } - checkIntervalLayout.setError( - if (errors.checkInterval != null) { - getString(errors.checkInterval!!) - } else { - null + private fun setupValidation() { + validationForm = form { + input(inputName, name = "Name") { + isNotEmpty().description(R.string.please_enter_name) + } + input(inputUrl, name = "URL") { + isNotEmpty().description(R.string.please_enter_url) + isUrl().description(R.string.please_enter_valid_url) + } + input(responseTimeoutInput, name = "Timeout", optional = true) { + isNumber().greaterThan(0) + .description(R.string.please_enter_networkTimeout) + } + input(responseValidationSearchTerm, name = "Search term") { + conditional(responseValidationSearchTerm.isVisibleCondition()) { + isNotEmpty().description(R.string.please_enter_search_term) } + } + input(sslCertificateInput, name = "Certificate Path", optional = true) { + isUri().hasScheme("file", "content") + .that { it.host != null } + .description(R.string.please_enter_validCertUri) + } + submitWith(toolbar.menu, R.id.commit) { + viewModel.commit { + setResult(RESULT_OK) + finish() + } + } + } + + // Validation script + scriptInputLayout.attach( + codeData = viewModel.validationScript, + visibility = viewModel.onValidationScriptVisibility(), + form = validationForm ) - responseValidationSearchTerm.error = if (errors.termSearch != null) { - getString(errors.termSearch!!) - } else { - null - } - scriptInputLayout.setError( - if (errors.javaScript != null) { - getString(errors.javaScript!!) - } else { - null - } + + // Check interval + checkIntervalLayout.attach( + valueData = viewModel.checkIntervalValue, + multiplierData = viewModel.checkIntervalUnit, + form = validationForm + ) + + // Retry Policy + retryPolicyLayout.attach( + timesData = viewModel.retryPolicyTimes, + minutesData = viewModel.retryPolicyMinutes, + form = validationForm ) } - override fun onSiteAdded() { - setResult(RESULT_OK) - finish() - overridePendingTransition(R.anim.fade_out, R.anim.fade_out) + override fun onResume() { + super.onResume() + appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { + appToolbar.dimenFloat(R.dimen.default_elevation) + } else { + 0f + } } - override fun scopeWhileAttached( - context: CoroutineContext, - exec: ScopeReceiver - ) = rootView.scopeWhileAttached(context, exec) - - override fun onBackPressed() = closeActivityWithReveal() - - private fun ValidationMode.validationContent() = when (this) { - STATUS_CODE -> null - TERM_SEARCH -> responseValidationSearchTerm.trimmedText() - JAVASCRIPT -> scriptInputLayout.getCode() + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + resultData: Intent? + ) { + super.onActivityResult(requestCode, resultCode, resultData) + if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) { + sslCertificateInput.setText(resultData?.data?.toString() ?: "") + } } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt deleted file mode 100644 index 7f5eb7f..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteActivityExt.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.ui.addsite - -import android.view.ViewAnimationUtils -import android.view.animation.AccelerateInterpolator -import android.view.animation.DecelerateInterpolator -import com.afollestad.nocknock.utilities.ext.onEnd -import com.afollestad.nocknock.viewcomponents.ext.conceal -import com.afollestad.nocknock.viewcomponents.ext.show -import kotlinx.android.synthetic.main.activity_addsite.rootView - -const val REVEAL_DURATION = 300L - -internal fun AddSiteActivity.circularRevealActivity() { - val circularReveal = - ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, 0f, revealRadius) - .apply { - duration = REVEAL_DURATION - interpolator = DecelerateInterpolator() - } - rootView.show() - circularReveal.start() -} - -internal fun AddSiteActivity.closeActivityWithReveal() { - if (isClosing) return - isClosing = true - ViewAnimationUtils.createCircularReveal(rootView, revealCx, revealCy, revealRadius, 0f) - .apply { - duration = REVEAL_DURATION - interpolator = AccelerateInterpolator() - onEnd { - rootView.conceal() - finish() - overridePendingTransition(0, 0) - } - start() - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt deleted file mode 100644 index 61e1b7e..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSitePresenter.kt +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.ui.addsite - -import androidx.annotation.CheckResult -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.WAITING -import com.afollestad.nocknock.data.ValidationMode -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import okhttp3.HttpUrl -import javax.inject.Inject - -/** @author Aidan Follestad (@afollestad) */ -data class InputErrors( - var name: Int? = null, - var url: Int? = null, - var checkInterval: Int? = null, - var termSearch: Int? = null, - var javaScript: Int? = null -) { - @CheckResult fun any(): Boolean { - return name != null || url != null || checkInterval != null || - termSearch != null || javaScript != null - } -} - -/** @author Aidan Follestad (@afollestad) */ -interface AddSitePresenter { - - fun takeView(view: AddSiteView) - - fun onUrlInputFocusChange( - focused: Boolean, - content: String - ) - - fun onValidationModeSelected(index: Int) - - fun commit( - name: String, - url: String, - checkInterval: Long, - validationMode: ValidationMode, - validationContent: String? - ) - - fun dropView() -} - -/** @author Aidan Follestad (@afollestad) */ -class RealAddSitePresenter @Inject constructor( - private val serverModelStore: ServerModelStore, - private val checkStatusManager: CheckStatusManager -) : AddSitePresenter { - - private var view: AddSiteView? = null - - override fun takeView(view: AddSiteView) { - this.view = view - } - - override fun onUrlInputFocusChange( - focused: Boolean, - content: String - ) { - if (content.isEmpty() || focused) { - return - } - val url = HttpUrl.parse(content) - if (url == null || - (url.scheme() != "http" && - url.scheme() != "https") - ) { - view?.showOrHideUrlSchemeWarning(true) - } else { - view?.showOrHideUrlSchemeWarning(false) - } - } - - override fun onValidationModeSelected(index: Int) = with(view!!) { - showOrHideValidationSearchTerm(index == 1) - showOrHideScriptInput(index == 2) - setValidationModeDescription( - when (index) { - 0 -> R.string.validation_mode_status_desc - 1 -> R.string.validation_mode_term_desc - 2 -> R.string.validation_mode_javascript_desc - else -> throw IllegalStateException("Unknown validation mode position: $index") - } - ) - } - - override fun commit( - name: String, - url: String, - checkInterval: Long, - validationMode: ValidationMode, - validationContent: String? - ) { - val inputErrors = InputErrors() - - if (name.isEmpty()) { - inputErrors.name = R.string.please_enter_name - } - if (url.isEmpty()) { - inputErrors.url = R.string.please_enter_url - } else if (HttpUrl.parse(url) == null) { - inputErrors.url = R.string.please_enter_valid_url - } - if (checkInterval <= 0) { - inputErrors.checkInterval = R.string.please_enter_check_interval - } - if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) { - inputErrors.termSearch = R.string.please_enter_search_term - } else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) { - inputErrors.javaScript = R.string.please_enter_javaScript - } - - if (inputErrors.any()) { - view?.setInputErrors(inputErrors) - return - } - - val newModel = ServerModel( - name = name, - url = url, - status = WAITING, - checkInterval = checkInterval, - validationMode = validationMode, - validationContent = validationContent - ) - - with(view!!) { - scopeWhileAttached(Main) { - launch(coroutineContext) { - setLoading() - val storedModel = async(IO) { - serverModelStore.put(newModel) - }.await() - - checkStatusManager.scheduleCheck( - site = storedModel, - rightNow = true, - cancelPrevious = true - ) - setDoneLoading() - onSiteAdded() - } - } - } - } - - override fun dropView() { - view = null - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt deleted file mode 100644 index 8c6084f..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteView.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.ui.addsite - -import androidx.annotation.StringRes -import com.afollestad.nocknock.utilities.ext.ScopeReceiver -import kotlin.coroutines.CoroutineContext - -/** @author Aidan Follestad (@afollestad) */ -interface AddSiteView { - - fun setLoading() - - fun setDoneLoading() - - fun showOrHideUrlSchemeWarning(show: Boolean) - - fun showOrHideValidationSearchTerm(show: Boolean) - - fun showOrHideScriptInput(show: Boolean) - - fun setValidationModeDescription(@StringRes res: Int) - - fun setInputErrors(errors: InputErrors) - - fun onSiteAdded() - - fun scopeWhileAttached( - context: CoroutineContext, - exec: ScopeReceiver - ) -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt new file mode 100644 index 0000000..d7d8ed5 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModel.kt @@ -0,0 +1,187 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui.addsite + +import androidx.annotation.CheckResult +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.OnLifecycleEvent +import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.AppDatabase +import com.afollestad.nocknock.data.model.Header +import com.afollestad.nocknock.data.model.RetryPolicy +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.SiteSettings +import com.afollestad.nocknock.data.model.Status.WAITING +import com.afollestad.nocknock.data.model.ValidationMode +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.data.model.ValidationResult +import com.afollestad.nocknock.data.putSite +import com.afollestad.nocknock.engine.validation.ValidationExecutor +import com.afollestad.nocknock.ui.ScopedViewModel +import com.afollestad.nocknock.utilities.ext.MINUTE +import com.afollestad.nocknock.utilities.livedata.map +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import java.lang.System.currentTimeMillis + +/** @author Aidan Follestad (@afollestad) */ +class AddSiteViewModel( + private val database: AppDatabase, + private val validationManager: ValidationExecutor, + mainDispatcher: CoroutineDispatcher, + private val ioDispatcher: CoroutineDispatcher +) : ScopedViewModel(mainDispatcher), LifecycleObserver { + + // Public properties + val name = MutableLiveData() + val tags = MutableLiveData() + val url = MutableLiveData() + val timeout = MutableLiveData() + val validationMode = MutableLiveData() + val validationSearchTerm = MutableLiveData() + val validationScript = MutableLiveData() + val checkIntervalValue = MutableLiveData() + val checkIntervalUnit = MutableLiveData() + val retryPolicyTimes = MutableLiveData() + val retryPolicyMinutes = MutableLiveData() + val headers = MutableLiveData>() + val certificateUri = MutableLiveData() + + @OnLifecycleEvent(ON_START) + fun setDefaults() { + timeout.value = 10000 + validationMode.value = STATUS_CODE + checkIntervalValue.value = 0 + checkIntervalUnit.value = MINUTE + retryPolicyMinutes.value = 0 + retryPolicyMinutes.value = 0 + tags.value = "" + headers.value = emptyList() + } + + private val isLoading = MutableLiveData() + + @CheckResult fun onIsLoading(): LiveData = isLoading + + @CheckResult fun onUrlWarningVisibility(): LiveData { + return url.map { + val parsed = HttpUrl.parse(it) + return@map it.isNotEmpty() && parsed == null + } + } + + @CheckResult fun onValidationModeDescription(): LiveData { + return validationMode.map { + when (it!!) { + STATUS_CODE -> R.string.validation_mode_status_desc + TERM_SEARCH -> R.string.validation_mode_term_desc + JAVASCRIPT -> R.string.validation_mode_javascript_desc + } + } + } + + @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } + + @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } + + // Actions + fun commit(done: () -> Unit) { + scope.launch { + val newModel = generateDbModel() ?: return@launch + isLoading.value = true + + val storedModel = withContext(ioDispatcher) { + database.putSite(newModel) + } + validationManager.scheduleValidation( + site = storedModel, + rightNow = true, + cancelPrevious = true + ) + + isLoading.value = false + done() + } + } + + // Utilities + @VisibleForTesting(otherwise = PRIVATE) + fun getCheckIntervalMs(): Long { + val value = checkIntervalValue.value ?: return 0 + val unit = checkIntervalUnit.value ?: return 0 + return value * unit + } + + @VisibleForTesting(otherwise = PRIVATE) + fun getValidationArgs(): String? { + return when (validationMode.value) { + TERM_SEARCH -> validationSearchTerm.value + JAVASCRIPT -> validationScript.value + else -> null + } + } + + private fun generateDbModel(): Site? { + val timeout = timeout.value ?: 10_000 + val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: "" + + val newSettings = SiteSettings( + validationIntervalMs = getCheckIntervalMs(), + validationMode = validationMode.value!!, + validationArgs = getValidationArgs(), + networkTimeout = timeout, + disabled = false, + certificate = certificateUri.value?.toString() + ) + + val newLastResult = ValidationResult( + timestampMs = currentTimeMillis(), + status = WAITING, + reason = null + ) + + val retryPolicyTimes = retryPolicyTimes.value ?: 0 + val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 + val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { + RetryPolicy( + count = retryPolicyTimes, + minutes = retryPolicyMinutes + ) + } else { + null + } + + return Site( + id = 0, + name = name.value!!.trim(), + url = url.value!!.trim(), + tags = cleanedTags, + settings = newSettings, + lastResult = newLastResult, + retryPolicy = newRetryPolicy, + headers = headers.value ?: emptyList() + ) + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt new file mode 100644 index 0000000..c524555 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/addsite/AddSiteViewModelExt.kt @@ -0,0 +1,99 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui.addsite + +import com.afollestad.nocknock.data.model.RetryPolicy +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.utilities.ext.DAY +import com.afollestad.nocknock.utilities.ext.HOUR +import com.afollestad.nocknock.utilities.ext.MINUTE +import com.afollestad.nocknock.utilities.ext.WEEK +import kotlin.math.ceil + +fun AddSiteViewModel.prePopulateFromModel(site: Site) { + val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!") + + name.value = site.name + tags.value = site.tags + url.value = site.url + timeout.value = settings.networkTimeout + + validationMode.value = settings.validationMode + when (settings.validationMode) { + TERM_SEARCH -> { + validationSearchTerm.value = settings.validationArgs + validationScript.value = null + } + JAVASCRIPT -> { + validationSearchTerm.value = null + validationScript.value = settings.validationArgs + } + else -> { + validationSearchTerm.value = null + validationScript.value = null + } + } + + setCheckInterval(settings.validationIntervalMs) + setRetryPolicy(site.retryPolicy) + headers.value = site.headers +} + +private fun AddSiteViewModel.setCheckInterval(interval: Long) { + when { + interval >= WEEK -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, WEEK) + checkIntervalUnit.value = WEEK + } + interval >= DAY -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, DAY) + checkIntervalUnit.value = DAY + } + interval >= HOUR -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, HOUR) + checkIntervalUnit.value = HOUR + } + interval >= MINUTE -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, MINUTE) + checkIntervalUnit.value = MINUTE + } + else -> { + checkIntervalValue.value = 0 + checkIntervalUnit.value = MINUTE + } + } +} + +private fun AddSiteViewModel.setRetryPolicy(policy: RetryPolicy?) { + if (policy == null) return + retryPolicyTimes.value = policy.count + retryPolicyMinutes.value = policy.minutes +} + +private fun getIntervalFromUnit( + millis: Long, + unit: Long +): Int { + val intervalFloat = millis.toFloat() + val byFloat = unit.toFloat() + return ceil(intervalFloat / byFloat).toInt() +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt index e9e3e83..aec76d8 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivity.kt @@ -1,129 +1,134 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.afollestad.nocknock.ui.main -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItems import com.afollestad.nocknock.R -import com.afollestad.nocknock.adapter.ServerAdapter -import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.adapter.SiteAdapter +import com.afollestad.nocknock.adapter.TagAdapter +import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver +import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.dialogs.AboutDialog -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.utilities.ext.ScopeReceiver -import com.afollestad.nocknock.utilities.ext.injector -import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver -import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver -import com.afollestad.nocknock.utilities.ext.scopeWhileAttached -import com.afollestad.nocknock.viewcomponents.ext.showOrHide +import com.afollestad.nocknock.notifications.NockNotificationManager +import com.afollestad.nocknock.ui.DarkModeSwitchActivity +import com.afollestad.nocknock.ui.NightMode.UNKNOWN +import com.afollestad.nocknock.utilities.providers.IntentProvider +import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility import kotlinx.android.synthetic.main.activity_main.fab import kotlinx.android.synthetic.main.activity_main.list -import kotlinx.android.synthetic.main.activity_main.rootView -import kotlinx.android.synthetic.main.activity_main.toolbar +import kotlinx.android.synthetic.main.activity_main.loadingProgress +import kotlinx.android.synthetic.main.include_app_bar.toolbar import kotlinx.android.synthetic.main.include_empty_view.emptyText -import javax.inject.Inject -import kotlin.coroutines.CoroutineContext +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import kotlinx.android.synthetic.main.activity_main.tags_list as tagsList /** @author Aidan Follestad (@afollestad) */ -class MainActivity : AppCompatActivity(), MainView { +class MainActivity : DarkModeSwitchActivity() { - private val intentReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent - ) = presenter.onBroadcast(intent) + private val notificationManager by inject() + private val intentProvider by inject() + + internal val viewModel by viewModel() + + private lateinit var siteAdapter: SiteAdapter + private lateinit var tagAdapter: TagAdapter + + private val statusUpdateReceiver by lazy { + StatusUpdateIntentReceiver(application, intentProvider) { + viewModel.postSiteUpdate(it) + } } - @Inject lateinit var presenter: MainPresenter - - private lateinit var adapter: ServerAdapter - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - injector().injectInto(this) setContentView(R.layout.activity_main) - presenter.takeView(this) + setupUi() - toolbar.inflateMenu(R.menu.menu_main) - toolbar.setOnMenuItemClickListener { item -> - if (item.itemId == R.id.about) { - AboutDialog.show(this) - } - return@setOnMenuItemClickListener true + notificationManager.createChannels() + + lifecycle.run { + addObserver(viewModel) + addObserver(statusUpdateReceiver) } - adapter = ServerAdapter(this::onSiteSelected) - - list.layoutManager = LinearLayoutManager(this) - list.adapter = adapter - list.addItemDecoration(DividerItemDecoration(this, VERTICAL)) - - fab.setOnClickListener { addSite() } + viewModel.onSites() + .observe(this, Observer { siteAdapter.set(it) }) + viewModel.onEmptyTextVisibility() + .toViewVisibility(this, emptyText) + viewModel.onTags() + .observe(this, Observer { tagAdapter.set(it) }) + viewModel.onTagsListVisibility() + .toViewVisibility(this, tagsList) + loadingProgress.observe(this, viewModel.onIsLoading()) processIntent(intent) } + private fun setupUi() { + toolbar.run { + inflateMenu(R.menu.menu_main) + menu.findItem(R.id.dark_mode) + .apply { + if (getCurrentNightMode() == UNKNOWN) { + isChecked = isDarkMode() + } else { + isVisible = false + } + } + setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.about -> AboutDialog.show(this@MainActivity) + R.id.dark_mode -> toggleDarkMode() + } + return@setOnMenuItemClickListener true + } + } + + siteAdapter = SiteAdapter(this::onSiteSelected) + list.run { + layoutManager = LinearLayoutManager(this@MainActivity) + adapter = siteAdapter + addItemDecoration(DividerItemDecoration(this@MainActivity, VERTICAL)) + } + + tagAdapter = TagAdapter(viewModel::onTagSelection) + tagsList.run { + layoutManager = LinearLayoutManager(this@MainActivity, HORIZONTAL, false) + adapter = tagAdapter + } + + fab.setOnClickListener { addSite() } + } + override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) intent?.let(::processIntent) } - override fun onResume() { - super.onResume() - val filter = IntentFilter().apply { - addAction(ACTION_STATUS_UPDATE) - } - safeRegisterReceiver(intentReceiver, filter) - presenter.resume() - } - - override fun onPause() { - super.onPause() - safeUnregisterReceiver(intentReceiver) - } - - override fun onDestroy() { - presenter.dropView() - super.onDestroy() - } - - override fun setModels(models: List) { - list.post { - adapter.set(models) - emptyText.showOrHide(models.isEmpty()) - } - } - - override fun updateModel(model: ServerModel) { - list.post { adapter.update(model) } - } - - override fun onSiteDeleted(model: ServerModel) { - list.post { - adapter.remove(model) - emptyText.showOrHide(adapter.itemCount == 0) - } - } - - override fun scopeWhileAttached( - context: CoroutineContext, - exec: ScopeReceiver - ) = rootView.scopeWhileAttached(context, exec) - private fun onSiteSelected( - model: ServerModel, + model: Site, longClick: Boolean ) { if (longClick) { @@ -131,8 +136,9 @@ class MainActivity : AppCompatActivity(), MainView { title(R.string.options) listItems(R.array.site_long_options) { _, i, _ -> when (i) { - 0 -> presenter.refreshSite(model) - 1 -> maybeRemoveSite(model) + 0 -> viewModel.refreshSite(model) + 1 -> addSiteForDuplication(model) + 2 -> maybeRemoveSite(model) } } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt index b955307..e11ca08 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainActivityExt.kt @@ -1,63 +1,73 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.afollestad.nocknock.ui.main import android.content.Intent import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel +import com.afollestad.nocknock.data.model.Site import com.afollestad.nocknock.toHtml import com.afollestad.nocknock.ui.addsite.AddSiteActivity -import com.afollestad.nocknock.ui.addsite.KEY_FAB_SIZE -import com.afollestad.nocknock.ui.addsite.KEY_FAB_X -import com.afollestad.nocknock.ui.addsite.KEY_FAB_Y -import com.afollestad.nocknock.ui.viewsite.KEY_VIEW_MODEL +import com.afollestad.nocknock.ui.viewsite.KEY_SITE import com.afollestad.nocknock.ui.viewsite.ViewSiteActivity import com.afollestad.nocknock.utilities.providers.RealIntentProvider.Companion.KEY_VIEW_NOTIFICATION_MODEL -import kotlinx.android.synthetic.main.activity_main.fab internal const val VIEW_SITE_RQ = 6923 internal const val ADD_SITE_RQ = 6969 +// ADD + internal fun MainActivity.addSite() { - startActivityForResult(intentToAdd(fab.x, fab.y, fab.measuredWidth), ADD_SITE_RQ) + startActivityForResult(intentToAdd(), ADD_SITE_RQ) } -private fun MainActivity.intentToAdd( - x: Float, - y: Float, - size: Int -) = Intent(this, AddSiteActivity::class.java).apply { - putExtra(KEY_FAB_X, x) - putExtra(KEY_FAB_Y, y) - putExtra(KEY_FAB_SIZE, size) - addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) +internal fun MainActivity.addSiteForDuplication(site: Site) { + startActivityForResult(intentToAdd(site), ADD_SITE_RQ) } -internal fun MainActivity.viewSite(model: ServerModel) { +private fun MainActivity.intentToAdd(model: Site? = null) = + Intent(this, AddSiteActivity::class.java).apply { + model?.let { putExtra(KEY_SITE, it) } + } + +// VIEW + +internal fun MainActivity.viewSite(model: Site) { startActivityForResult(intentToView(model), VIEW_SITE_RQ) } -private fun MainActivity.intentToView(model: ServerModel) = +private fun MainActivity.intentToView(model: Site) = Intent(this, ViewSiteActivity::class.java).apply { - putExtra(KEY_VIEW_MODEL, model) + putExtra(KEY_SITE, model) } -internal fun MainActivity.maybeRemoveSite(model: ServerModel) { +// MISC + +internal fun MainActivity.maybeRemoveSite(model: Site) { MaterialDialog(this).show { title(R.string.remove_site) message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml()) - positiveButton(R.string.remove) { presenter.removeSite(model) } + positiveButton(R.string.remove) { viewModel.removeSite(model) } negativeButton(android.R.string.cancel) } } internal fun MainActivity.processIntent(intent: Intent) { if (intent.hasExtra(KEY_VIEW_NOTIFICATION_MODEL)) { - val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as ServerModel + val model = intent.getSerializableExtra(KEY_VIEW_NOTIFICATION_MODEL) as Site viewSite(model) } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt deleted file mode 100644 index 69f3bdb..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainPresenter.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.ui.main - -import android.content.Intent -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager -import com.afollestad.nocknock.notifications.NockNotificationManager -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** @author Aidan Follestad (@afollestad) */ -interface MainPresenter { - - fun takeView(view: MainView) - - fun onBroadcast(intent: Intent) - - fun resume() - - fun refreshSite(site: ServerModel) - - fun removeSite(site: ServerModel) - - fun dropView() -} - -/** @author Aidan Follestad (@afollestad) */ -class RealMainPresenter @Inject constructor( - private val serverModelStore: ServerModelStore, - private val notificationManager: NockNotificationManager, - private val checkStatusManager: CheckStatusManager -) : MainPresenter { - - private var view: MainView? = null - - override fun takeView(view: MainView) { - this.view = view - notificationManager.createChannels() - ensureCheckJobs() - } - - override fun onBroadcast(intent: Intent) { - if (intent.action == ACTION_STATUS_UPDATE) { - val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return - view?.updateModel(model) - } - } - - override fun resume() { - notificationManager.cancelStatusNotifications() - view!!.run { - setModels(listOf()) - scopeWhileAttached(Main) { - launch(coroutineContext) { - val models = async(IO) { - serverModelStore.get() - }.await() - setModels(models) - } - } - } - } - - override fun refreshSite(site: ServerModel) { - checkStatusManager.scheduleCheck( - site = site, - rightNow = true, - cancelPrevious = true - ) - } - - override fun removeSite(site: ServerModel) { - checkStatusManager.cancelCheck(site) - notificationManager.cancelStatusNotification(site) - view!!.scopeWhileAttached(Main) { - launch(coroutineContext) { - async(IO) { serverModelStore.delete(site) }.await() - view?.onSiteDeleted(site) - } - } - } - - override fun dropView() { - view = null - } - - private fun ensureCheckJobs() { - view!!.scopeWhileAttached(IO) { - launch(coroutineContext) { - checkStatusManager.ensureScheduledChecks() - } - } - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt deleted file mode 100644 index 34665d2..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/main/MainView.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.ui.main - -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.utilities.ext.ScopeReceiver -import kotlin.coroutines.CoroutineContext - -/** @author Aidan Follestad (@afollestad) */ -interface MainView { - - fun setModels(models: List) - - fun updateModel(model: ServerModel) - - fun onSiteDeleted(model: ServerModel) - - fun scopeWhileAttached( - context: CoroutineContext, - exec: ScopeReceiver - ) -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt new file mode 100644 index 0000000..bad64fc --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/main/MainViewModel.kt @@ -0,0 +1,157 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui.main + +import androidx.annotation.CheckResult +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.OnLifecycleEvent +import com.afollestad.nocknock.data.AppDatabase +import com.afollestad.nocknock.data.allSites +import com.afollestad.nocknock.data.deleteSite +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.engine.validation.ValidationExecutor +import com.afollestad.nocknock.notifications.NockNotificationManager +import com.afollestad.nocknock.ui.ScopedViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** @author Aidan Follestad (@afollestad) */ +class MainViewModel( + private val database: AppDatabase, + private val notificationManager: NockNotificationManager, + private val validationManager: ValidationExecutor, + mainDispatcher: CoroutineDispatcher, + private val ioDispatcher: CoroutineDispatcher +) : ScopedViewModel(mainDispatcher), LifecycleObserver { + + private val sites = MutableLiveData>() + private val isLoading = MutableLiveData() + private val emptyTextVisibility = MutableLiveData() + private val tags = MutableLiveData>() + private val tagsListVisibility = MutableLiveData() + + @CheckResult fun onSites(): LiveData> = sites + + @CheckResult fun onIsLoading(): LiveData = isLoading + + @CheckResult fun onEmptyTextVisibility(): LiveData = emptyTextVisibility + + @CheckResult fun onTags(): LiveData> = tags + + @CheckResult fun onTagsListVisibility(): LiveData = tagsListVisibility + + @OnLifecycleEvent(ON_RESUME) + fun onResume() = loadSites(emptyList()) + + fun onTagSelection(tags: List) = loadSites(tags) + + fun postSiteUpdate(model: Site) { + val currentSites = sites.value ?: return + val index = currentSites.indexOfFirst { it.id == model.id } + if (index == -1) return + sites.value = currentSites.toMutableList() + .apply { + this[index] = model + } + } + + fun refreshSite(model: Site) { + validationManager.scheduleValidation( + site = model, + rightNow = true, + cancelPrevious = true + ) + } + + fun removeSite(model: Site) { + validationManager.cancelScheduledValidation(model) + notificationManager.cancelStatusNotification(model) + + scope.launch { + isLoading.value = true + withContext(ioDispatcher) { database.deleteSite(model) } + + val currentSites = sites.value ?: return@launch + val index = currentSites.indexOfFirst { it.id == model.id } + isLoading.value = false + if (index == -1) return@launch + + val newSitesList = currentSites.toMutableList() + .apply { + removeAt(index) + } + sites.value = newSitesList + emptyTextVisibility.value = newSitesList.isEmpty() + } + } + + private fun loadSites(forTags: List) { + scope.launch { + notificationManager.cancelStatusNotifications() + emptyTextVisibility.value = false + isLoading.value = true + + val unfiltered = withContext(ioDispatcher) { + database.allSites() + } + var result = unfiltered + + if (forTags.isNotEmpty()) { + result = result.filter { site -> + val itemTags = site.tags.toLowerCase() + .split(",") + itemTags.any { tag -> forTags.contains(tag) } + } + } + + sites.value = result + ensureCheckJobs() + isLoading.value = false + emptyTextVisibility.value = result.isEmpty() + + val tagsValues = pullOutTags(unfiltered) + tags.value = tagsValues + tagsListVisibility.value = tagsValues.isNotEmpty() + } + } + + private suspend fun ensureCheckJobs() { + withContext(ioDispatcher) { + validationManager.ensureScheduledValidations() + } + } + + private fun pullOutTags(sites: List): List { + return mutableListOf().apply { + for (site in sites) { + val splitTags = site.tags.toLowerCase() + .split(',') + splitTags + .filter { it.isNotEmpty() } + .forEach { tag -> + if (!this.contains(tag)) { + this.add(tag) + } + } + } + sort() + } + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt index 59d2a69..2aa312c 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivity.kt @@ -1,104 +1,184 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.afollestad.nocknock.ui.viewsite import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter +import android.content.Intent.ACTION_OPEN_DOCUMENT +import android.content.Intent.CATEGORY_OPENABLE import android.os.Bundle import android.widget.ArrayAdapter -import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.LAST_CHECK_NONE -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ValidationMode -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.STATUS_CODE -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.data.indexToValidationMode -import com.afollestad.nocknock.data.textRes -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.utilities.ext.ScopeReceiver -import com.afollestad.nocknock.utilities.ext.formatDate -import com.afollestad.nocknock.utilities.ext.injector -import com.afollestad.nocknock.utilities.ext.safeRegisterReceiver -import com.afollestad.nocknock.utilities.ext.safeUnregisterReceiver -import com.afollestad.nocknock.utilities.ext.scopeWhileAttached +import com.afollestad.nocknock.broadcasts.StatusUpdateIntentReceiver +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.ValidationMode +import com.afollestad.nocknock.ui.DarkModeSwitchActivity +import com.afollestad.nocknock.utilities.ext.onTextChanged +import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection +import com.afollestad.nocknock.utilities.livedata.distinct +import com.afollestad.nocknock.utilities.providers.IntentProvider import com.afollestad.nocknock.viewcomponents.ext.dimenFloat -import com.afollestad.nocknock.viewcomponents.ext.onItemSelected +import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition import com.afollestad.nocknock.viewcomponents.ext.onScroll -import com.afollestad.nocknock.viewcomponents.ext.showOrHide -import com.afollestad.nocknock.viewcomponents.ext.trimmedText +import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData +import com.afollestad.nocknock.viewcomponents.livedata.toViewText +import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility +import com.afollestad.vvalidator.form +import com.afollestad.vvalidator.form.Form import kotlinx.android.synthetic.main.activity_viewsite.checkIntervalLayout -import kotlinx.android.synthetic.main.activity_viewsite.disableChecksButton -import kotlinx.android.synthetic.main.activity_viewsite.doneBtn +import kotlinx.android.synthetic.main.activity_viewsite.headersLayout import kotlinx.android.synthetic.main.activity_viewsite.iconStatus import kotlinx.android.synthetic.main.activity_viewsite.inputName +import kotlinx.android.synthetic.main.activity_viewsite.inputTags import kotlinx.android.synthetic.main.activity_viewsite.inputUrl import kotlinx.android.synthetic.main.activity_viewsite.loadingProgress +import kotlinx.android.synthetic.main.activity_viewsite.responseTimeoutInput import kotlinx.android.synthetic.main.activity_viewsite.responseValidationMode import kotlinx.android.synthetic.main.activity_viewsite.responseValidationSearchTerm -import kotlinx.android.synthetic.main.activity_viewsite.rootView +import kotlinx.android.synthetic.main.activity_viewsite.retryPolicyLayout import kotlinx.android.synthetic.main.activity_viewsite.scriptInputLayout import kotlinx.android.synthetic.main.activity_viewsite.scrollView +import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateBrowse +import kotlinx.android.synthetic.main.activity_viewsite.sslCertificateInput import kotlinx.android.synthetic.main.activity_viewsite.textLastCheckResult import kotlinx.android.synthetic.main.activity_viewsite.textNextCheck import kotlinx.android.synthetic.main.activity_viewsite.textUrlWarning -import kotlinx.android.synthetic.main.activity_viewsite.toolbar import kotlinx.android.synthetic.main.activity_viewsite.validationModeDescription -import javax.inject.Inject -import kotlin.coroutines.CoroutineContext +import kotlinx.android.synthetic.main.include_app_bar.toolbar +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import kotlinx.android.synthetic.main.include_app_bar.app_toolbar as appToolbar +import kotlinx.android.synthetic.main.include_app_bar.toolbar_title as toolbarTitle /** @author Aidan Follestad (@afollestad) */ -class ViewSiteActivity : AppCompatActivity(), ViewSiteView { +class ViewSiteActivity : DarkModeSwitchActivity() { + companion object { + private const val SELECT_CERT_FILE_RQ = 23 + } - @Inject lateinit var presenter: ViewSitePresenter + internal val viewModel by viewModel() + private lateinit var validationForm: Form - private val intentReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent - ) = presenter.onBroadcast(intent) + private val intentProvider by inject() + private val statusUpdateReceiver by lazy { + StatusUpdateIntentReceiver(application, intentProvider) { + viewModel.setModel(it) + } } @SuppressLint("SetTextI18n") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - injector().injectInto(this) setContentView(R.layout.activity_viewsite) + // Populate view model with initial data + val model = intent.getSerializableExtra(KEY_SITE) as Site + viewModel.setModel(model) + + setupUi() + setupValidation() + lifecycle.run { + addObserver(viewModel) + addObserver(statusUpdateReceiver) + } + + // Loading + loadingProgress.observe(this, viewModel.onIsLoading()) + + // Status + viewModel.status.observe(this, Observer { + iconStatus.setStatus(it) + invalidateMenuForStatus(it) + }) + + // Name + inputName.attachLiveData(this, viewModel.name) + + // Tags + inputTags.attachLiveData(this, viewModel.tags) + + // Url + inputUrl.attachLiveData(this, viewModel.url) + viewModel.onUrlWarningVisibility() + .toViewVisibility(this, textUrlWarning) + + // Timeout + responseTimeoutInput.attachLiveData(this, viewModel.timeout) + + // Validation mode + responseValidationMode.attachLiveData( + lifecycleOwner = this, + data = viewModel.validationMode, + outTransformer = { ValidationMode.fromIndex(it) }, + inTransformer = { it.toIndex() } + ) + viewModel.onValidationModeDescription() + .toViewText(this, validationModeDescription) + + // Validation search term + responseValidationSearchTerm.attachLiveData(this, viewModel.validationSearchTerm) + viewModel.onValidationSearchTermVisibility() + .toViewVisibility(this, responseValidationSearchTerm) + + // SSL certificate + sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it } + viewModel.certificateUri.distinct() + .observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) }) + + // Headers + headersLayout.attach(viewModel.headers) + + // Last/next check + viewModel.onLastCheckResultText() + .toViewText(this, textLastCheckResult) + viewModel.onNextCheckText() + .toViewText(this, textNextCheck) + } + + private fun setupUi() { + toolbarTitle.text = "" toolbar.run { + setNavigationIcon(R.drawable.ic_action_close) setNavigationOnClickListener { finish() } inflateMenu(R.menu.menu_viewsite) + menu.findItem(R.id.refresh) .setActionView(R.layout.menu_item_refresh_icon) .apply { - actionView.setOnClickListener { presenter.checkNow() } + actionView.setOnClickListener { viewModel.checkNow() } } + setOnMenuItemClickListener { - maybeRemoveSite() - return@setOnMenuItemClickListener true + when (it.itemId) { + R.id.remove -> maybeRemoveSite() + R.id.disableChecks -> maybeDisableChecks() + } + true } } scrollView.onScroll { - toolbar.elevation = if (it > toolbar.height / 4) { - toolbar.dimenFloat(R.dimen.default_elevation) + appToolbar.elevation = if (it > appToolbar.measuredHeight / 2) { + appToolbar.dimenFloat(R.dimen.default_elevation) } else { 0f } } - inputUrl.setOnFocusChangeListener { _, hasFocus -> - presenter.onUrlInputFocusChange(hasFocus, inputUrl.trimmedText()) - } - val validationOptionsAdapter = ArrayAdapter( this, R.layout.list_item_spinner, @@ -107,150 +187,105 @@ class ViewSiteActivity : AppCompatActivity(), ViewSiteView { validationOptionsAdapter.setDropDownViewResource(R.layout.list_item_spinner_dropdown) responseValidationMode.adapter = validationOptionsAdapter - responseValidationMode.onItemSelected(presenter::onValidationModeSelected) + // Disabled button + viewModel.onDisableChecksVisibility() + .observe(this, Observer { + toolbar.menu.findItem(R.id.disableChecks) + .isVisible = it + }) - doneBtn.setOnClickListener { - val checkInterval = checkIntervalLayout.getSelectedCheckInterval() - val validationMode = - responseValidationMode.selectedItemPosition.indexToValidationMode() + // Done item text + viewModel.onDoneButtonText() + .observe(this, Observer { + toolbar.menu.findItem(R.id.commit) + .setTitle(it) + }) - presenter.commit( - name = inputName.trimmedText(), - url = inputUrl.trimmedText(), - checkInterval = checkInterval, - validationMode = validationMode, - validationContent = validationMode.validationContent() - ) + // SSL certificate + sslCertificateBrowse.setOnClickListener { + val intent = Intent(ACTION_OPEN_DOCUMENT).apply { + addCategory(CATEGORY_OPENABLE) + type = "*/*" + } + startActivityForResult(intent, SELECT_CERT_FILE_RQ) + } + } + + private fun setupValidation() { + validationForm = form { + input(inputName, name = "Name") { + isNotEmpty().description(R.string.please_enter_name) + } + input(inputUrl, name = "URL") { + isNotEmpty().description(R.string.please_enter_url) + isUrl().description(R.string.please_enter_valid_url) + } + input(responseValidationSearchTerm, name = "Search term") { + conditional(responseValidationSearchTerm.isVisibleCondition()) { + isNotEmpty().description(R.string.please_enter_search_term) + } + } + input(responseTimeoutInput, name = "Timeout", optional = true) { + isNumber().greaterThan(0) + .description(R.string.please_enter_networkTimeout) + } + input(sslCertificateInput, name = "Certificate Path", optional = true) { + isUri().hasScheme("file", "content") + .that { it.host != null } + .description(R.string.please_enter_validCertUri) + } + submitWith(toolbar.menu, R.id.commit) { + viewModel.commit { finish() } + } } - disableChecksButton.setOnClickListener { maybeDisableChecks() } + // Validation script + scriptInputLayout.attach( + codeData = viewModel.validationScript, + visibility = viewModel.onValidationScriptVisibility(), + form = validationForm + ) - presenter.takeView(this, intent) + // Check interval + checkIntervalLayout.attach( + valueData = viewModel.checkIntervalValue, + multiplierData = viewModel.checkIntervalUnit, + form = validationForm + ) + + // Retry Policy + retryPolicyLayout.attach( + timesData = viewModel.retryPolicyTimes, + minutesData = viewModel.retryPolicyMinutes, + form = validationForm + ) + } + + override fun onResume() { + super.onResume() + appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) { + appToolbar.dimenFloat(R.dimen.default_elevation) + } else { + 0f + } + } + + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + resultData: Intent? + ) { + super.onActivityResult(requestCode, resultCode, resultData) + if (requestCode == SELECT_CERT_FILE_RQ && resultCode == RESULT_OK) { + sslCertificateInput.setText(resultData?.data?.toString() ?: "") + } } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - presenter.onNewIntent(intent) - } - - override fun onDestroy() { - presenter.dropView() - super.onDestroy() - } - - override fun setLoading() = loadingProgress.setLoading() - - override fun setDoneLoading() = loadingProgress.setDone() - - override fun showOrHideUrlSchemeWarning(show: Boolean) { - textUrlWarning.showOrHide(show) - if (show) { - textUrlWarning.setText(R.string.warning_http_url) + if (intent != null && intent.hasExtra(KEY_SITE)) { + val newModel = intent.getSerializableExtra(KEY_SITE) as Site + viewModel.setModel(newModel) } } - - override fun showOrHideValidationSearchTerm(show: Boolean) = - responseValidationSearchTerm.showOrHide(show) - - override fun showOrHideScriptInput(show: Boolean) = scriptInputLayout.showOrHide(show) - - override fun setValidationModeDescription(res: Int) = validationModeDescription.setText(res) - - override fun displayModel(model: ServerModel) = with(model) { - iconStatus.setStatus(this.status) - inputName.setText(this.name) - inputUrl.setText(this.url) - - if (this.lastCheck == LAST_CHECK_NONE) { - textLastCheckResult.setText(R.string.none) - } else { - val statusText = this.status.textRes() - textLastCheckResult.text = if (statusText == 0) { - this.reason - } else { - getString(statusText) - } - } - - if (this.disabled) { - textNextCheck.setText(R.string.auto_checks_disabled) - } else { - textNextCheck.text = (this.lastCheck + this.checkInterval).formatDate() - } - checkIntervalLayout.set(this.checkInterval) - - responseValidationMode.setSelection(validationMode.value - 1) - when (this.validationMode) { - TERM_SEARCH -> responseValidationSearchTerm.setText(this.validationContent ?: "") - JAVASCRIPT -> scriptInputLayout.setCode(this.validationContent) - else -> { - responseValidationSearchTerm.setText("") - scriptInputLayout.clear() - } - } - - disableChecksButton.showOrHide(!this.disabled) - doneBtn.setText( - if (this.disabled) R.string.renable_and_save_changes - else R.string.save_changes - ) - - invalidateMenuForStatus(model) - } - - override fun setInputErrors(errors: InputErrors) { - inputName.error = if (errors.name != null) { - getString(errors.name!!) - } else { - null - } - inputUrl.error = if (errors.url != null) { - getString(errors.url!!) - } else { - null - } - checkIntervalLayout.setError( - if (errors.checkInterval != null) { - getString(errors.checkInterval!!) - } else { - null - } - ) - responseValidationSearchTerm.error = if (errors.termSearch != null) { - getString(errors.termSearch!!) - } else { - null - } - scriptInputLayout.setError( - if (errors.javaScript != null) { - getString(errors.javaScript!!) - } else { - null - } - ) - } - - override fun scopeWhileAttached( - context: CoroutineContext, - exec: ScopeReceiver - ) = rootView.scopeWhileAttached(context, exec) - - override fun onResume() { - super.onResume() - val filter = IntentFilter().apply { - addAction(ACTION_STATUS_UPDATE) - } - safeRegisterReceiver(intentReceiver, filter) - } - - override fun onPause() { - super.onPause() - safeUnregisterReceiver(intentReceiver) - } - - private fun ValidationMode.validationContent() = when (this) { - STATUS_CODE -> null - TERM_SEARCH -> responseValidationSearchTerm.trimmedText() - JAVASCRIPT -> scriptInputLayout.getCode() - } } diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt index 41858c4..ab0a6ea 100644 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteActivityExt.kt @@ -1,46 +1,59 @@ -/* - * Licensed under Apache-2.0 - * +/** * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.afollestad.nocknock.ui.viewsite import android.widget.ImageView import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.isPending +import com.afollestad.nocknock.data.model.Status +import com.afollestad.nocknock.data.model.isPending import com.afollestad.nocknock.toHtml import com.afollestad.nocknock.utilities.ext.animateRotation -import kotlinx.android.synthetic.main.activity_viewsite.toolbar +import kotlinx.android.synthetic.main.include_app_bar.toolbar + +const val KEY_SITE = "site_model" internal fun ViewSiteActivity.maybeRemoveSite() { - val model = presenter.currentModel() + val model = viewModel.site MaterialDialog(this).show { title(R.string.remove_site) message(text = context.getString(R.string.remove_site_prompt, model.name).toHtml()) - positiveButton(R.string.remove) { presenter.removeSite() } + positiveButton(R.string.remove) { + viewModel.removeSite { finish() } + } negativeButton(android.R.string.cancel) } } internal fun ViewSiteActivity.maybeDisableChecks() { - val model = presenter.currentModel() + val model = viewModel.site MaterialDialog(this).show { title(R.string.disable_automatic_checks) message( text = context.getString(R.string.disable_automatic_checks_prompt, model.name).toHtml() ) - positiveButton(R.string.disable) { presenter.disableChecks() } + positiveButton(R.string.disable) { viewModel.disableSite() } negativeButton(android.R.string.cancel) } } -internal fun ViewSiteActivity.invalidateMenuForStatus(model: ServerModel) { +internal fun ViewSiteActivity.invalidateMenuForStatus(status: Status) { val refreshIcon = toolbar.menu.findItem(R.id.refresh) .actionView as ImageView - - if (model.status.isPending()) { + if (status.isPending()) { refreshIcon.animateRotation() } else { refreshIcon.run { diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt deleted file mode 100644 index 376b3c0..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSitePresenter.kt +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.ui.viewsite - -import android.content.Intent -import androidx.annotation.CheckResult -import com.afollestad.nocknock.R -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.data.ServerStatus.WAITING -import com.afollestad.nocknock.data.ValidationMode -import com.afollestad.nocknock.data.ValidationMode.JAVASCRIPT -import com.afollestad.nocknock.data.ValidationMode.TERM_SEARCH -import com.afollestad.nocknock.engine.db.ServerModelStore -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.ACTION_STATUS_UPDATE -import com.afollestad.nocknock.engine.statuscheck.CheckStatusJob.Companion.KEY_UPDATE_MODEL -import com.afollestad.nocknock.engine.statuscheck.CheckStatusManager -import com.afollestad.nocknock.notifications.NockNotificationManager -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import okhttp3.HttpUrl -import org.jetbrains.annotations.TestOnly -import javax.inject.Inject - -const val KEY_VIEW_MODEL = "site_model" - -/** @author Aidan Follestad (@afollestad) */ -data class InputErrors( - var name: Int? = null, - var url: Int? = null, - var checkInterval: Int? = null, - var termSearch: Int? = null, - var javaScript: Int? = null -) { - @CheckResult fun any(): Boolean { - return name != null || url != null || checkInterval != null || - termSearch != null || javaScript != null - } -} - -/** @author Aidan Follestad (@afollestad) */ -interface ViewSitePresenter { - - fun takeView( - view: ViewSiteView, - intent: Intent - ) - - fun onBroadcast(intent: Intent) - - fun onNewIntent(intent: Intent?) - - fun onUrlInputFocusChange( - focused: Boolean, - content: String - ) - - fun onValidationModeSelected(index: Int) - - fun commit( - name: String, - url: String, - checkInterval: Long, - validationMode: ValidationMode, - validationContent: String? - ) - - fun checkNow() - - fun disableChecks() - - fun removeSite() - - fun currentModel(): ServerModel - - fun dropView() -} - -/** @author Aidan Follestad (@afollestad) */ -class RealViewSitePresenter @Inject constructor( - private val serverModelStore: ServerModelStore, - private val checkStatusManager: CheckStatusManager, - private val notificationManager: NockNotificationManager -) : ViewSitePresenter { - - private var view: ViewSiteView? = null - private var currentModel: ServerModel? = null - - override fun takeView( - view: ViewSiteView, - intent: Intent - ) { - this.currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel - this.view = view.apply { - displayModel(currentModel!!) - } - } - - override fun onBroadcast(intent: Intent) { - if (intent.action == ACTION_STATUS_UPDATE) { - val model = intent.getSerializableExtra(KEY_UPDATE_MODEL) as? ServerModel ?: return - this.currentModel = model - view?.displayModel(model) - } - } - - override fun onNewIntent(intent: Intent?) { - if (intent != null && intent.hasExtra(KEY_VIEW_MODEL)) { - currentModel = intent.getSerializableExtra(KEY_VIEW_MODEL) as ServerModel - view?.displayModel(currentModel!!) - } - } - - override fun onUrlInputFocusChange( - focused: Boolean, - content: String - ) { - if (content.isEmpty() || focused) { - return - } - val url = HttpUrl.parse(content) - if (url == null || - (url.scheme() != "http" && - url.scheme() != "https") - ) { - view?.showOrHideUrlSchemeWarning(true) - } else { - view?.showOrHideUrlSchemeWarning(false) - } - } - - override fun onValidationModeSelected(index: Int) = with(view!!) { - showOrHideValidationSearchTerm(index == 1) - showOrHideScriptInput(index == 2) - setValidationModeDescription( - when (index) { - 0 -> R.string.validation_mode_status_desc - 1 -> R.string.validation_mode_term_desc - 2 -> R.string.validation_mode_javascript_desc - else -> throw IllegalStateException("Unknown validation mode position: $index") - } - ) - } - - override fun commit( - name: String, - url: String, - checkInterval: Long, - validationMode: ValidationMode, - validationContent: String? - ) { - val inputErrors = InputErrors() - - if (name.isEmpty()) { - inputErrors.name = R.string.please_enter_name - } - if (url.isEmpty()) { - inputErrors.url = R.string.please_enter_url - } else if (HttpUrl.parse(url) == null) { - inputErrors.url = R.string.please_enter_valid_url - } - if (checkInterval <= 0) { - inputErrors.checkInterval = R.string.please_enter_check_interval - } - if (validationMode == TERM_SEARCH && validationContent.isNullOrEmpty()) { - inputErrors.termSearch = R.string.please_enter_search_term - } else if (validationMode == JAVASCRIPT && validationContent.isNullOrEmpty()) { - inputErrors.javaScript = R.string.please_enter_javaScript - } - - if (inputErrors.any()) { - view?.setInputErrors(inputErrors) - return - } - - val newModel = currentModel!!.copy( - name = name, - url = url, - status = WAITING, - checkInterval = checkInterval, - validationMode = validationMode, - validationContent = validationContent, - disabled = false - ) - - with(view!!) { - scopeWhileAttached(Main) { - launch(coroutineContext) { - setLoading() - async(IO) { serverModelStore.update(newModel) }.await() - checkStatusManager.scheduleCheck( - site = newModel, - rightNow = true, - cancelPrevious = true - ) - setDoneLoading() - view?.finish() - } - } - } - } - - override fun checkNow() = with(view!!) { - val checkModel = currentModel!!.copy( - status = WAITING - ) - view?.displayModel(checkModel) - checkStatusManager.scheduleCheck( - site = checkModel, - rightNow = true, - cancelPrevious = true - ) - } - - override fun disableChecks() { - val site = currentModel!! - checkStatusManager.cancelCheck(site) - notificationManager.cancelStatusNotification(site) - - with(view!!) { - scopeWhileAttached(Main) { - launch(coroutineContext) { - setLoading() - currentModel = currentModel!!.copy(disabled = true) - async(IO) { serverModelStore.update(currentModel!!) }.await() - setDoneLoading() - view?.displayModel(currentModel!!) - } - } - } - } - - override fun removeSite() { - val site = currentModel!! - checkStatusManager.cancelCheck(site) - notificationManager.cancelStatusNotification(site) - - with(view!!) { - scopeWhileAttached(Main) { - launch(coroutineContext) { - setLoading() - async(IO) { serverModelStore.delete(site) }.await() - setDoneLoading() - view?.finish() - } - } - } - } - - override fun currentModel() = this.currentModel!! - - override fun dropView() { - view = null - currentModel = null - } - - @TestOnly fun setModel(model: ServerModel) { - this.currentModel = model - } -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt deleted file mode 100644 index 0cbd515..0000000 --- a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteView.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Licensed under Apache-2.0 - * - * Designed and developed by Aidan Follestad (@afollestad) - */ -package com.afollestad.nocknock.ui.viewsite - -import androidx.annotation.StringRes -import com.afollestad.nocknock.data.ServerModel -import com.afollestad.nocknock.utilities.ext.ScopeReceiver -import kotlin.coroutines.CoroutineContext - -/** @author Aidan Follestad (@afollestad) */ -interface ViewSiteView { - - fun setLoading() - - fun setDoneLoading() - - fun displayModel(model: ServerModel) - - fun showOrHideUrlSchemeWarning(show: Boolean) - - fun showOrHideValidationSearchTerm(show: Boolean) - - fun showOrHideScriptInput(show: Boolean) - - fun setValidationModeDescription(@StringRes res: Int) - - fun setInputErrors(errors: InputErrors) - - fun scopeWhileAttached( - context: CoroutineContext, - exec: ScopeReceiver - ) - - fun finish() -} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt new file mode 100644 index 0000000..b5c9f93 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModel.kt @@ -0,0 +1,268 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui.viewsite + +import androidx.annotation.CheckResult +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.afollestad.nocknock.R +import com.afollestad.nocknock.data.AppDatabase +import com.afollestad.nocknock.data.deleteSite +import com.afollestad.nocknock.data.model.Header +import com.afollestad.nocknock.data.model.RetryPolicy +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.Status +import com.afollestad.nocknock.data.model.Status.WAITING +import com.afollestad.nocknock.data.model.ValidationMode +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.STATUS_CODE +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.data.model.ValidationResult +import com.afollestad.nocknock.data.model.textRes +import com.afollestad.nocknock.data.updateSite +import com.afollestad.nocknock.engine.validation.ValidationExecutor +import com.afollestad.nocknock.notifications.NockNotificationManager +import com.afollestad.nocknock.ui.ScopedViewModel +import com.afollestad.nocknock.utilities.ext.formatDate +import com.afollestad.nocknock.utilities.livedata.map +import com.afollestad.nocknock.utilities.livedata.zip +import com.afollestad.nocknock.utilities.providers.StringProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import java.lang.System.currentTimeMillis + +/** @author Aidan Follestad (@afollestad) */ +class ViewSiteViewModel( + private val stringProvider: StringProvider, + private val database: AppDatabase, + private val notificationManager: NockNotificationManager, + private val validationManager: ValidationExecutor, + mainDispatcher: CoroutineDispatcher, + private val ioDispatcher: CoroutineDispatcher +) : ScopedViewModel(mainDispatcher), LifecycleObserver { + + lateinit var site: Site + + // Public properties + val status = MutableLiveData() + val name = MutableLiveData() + val tags = MutableLiveData() + val url = MutableLiveData() + val timeout = MutableLiveData() + val validationMode = MutableLiveData() + val validationSearchTerm = MutableLiveData() + val validationScript = MutableLiveData() + val checkIntervalValue = MutableLiveData() + val checkIntervalUnit = MutableLiveData() + val retryPolicyTimes = MutableLiveData() + val retryPolicyMinutes = MutableLiveData() + val headers = MutableLiveData>() + val certificateUri = MutableLiveData() + internal val disabled = MutableLiveData() + internal val lastResult = MutableLiveData() + + private val isLoading = MutableLiveData() + + @CheckResult fun onIsLoading(): LiveData = isLoading + + @CheckResult fun onUrlWarningVisibility(): LiveData { + return url.map { + val parsed = HttpUrl.parse(it) + return@map it.isNotEmpty() && parsed == null + } + } + + @CheckResult fun onValidationModeDescription(): LiveData { + return validationMode.map { + when (it!!) { + STATUS_CODE -> R.string.validation_mode_status_desc + TERM_SEARCH -> R.string.validation_mode_term_desc + JAVASCRIPT -> R.string.validation_mode_javascript_desc + } + } + } + + @CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH } + + @CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT } + + @CheckResult fun onDisableChecksVisibility(): LiveData = disabled.map { !it } + + @CheckResult fun onDoneButtonText(): LiveData = + disabled.map { + if (it) R.string.renable_and_save_changes + else R.string.save_changes + } + + @CheckResult fun onLastCheckResultText(): LiveData = lastResult.map { + if (it == null) { + stringProvider.get(R.string.none) + } else { + val statusText = it.status.textRes() + if (statusText == 0) { + it.reason + } else { + stringProvider.get(statusText) + } + } + } + + @CheckResult fun onNextCheckText(): LiveData { + return zip(disabled, lastResult) + .map { + val disabled = it.first + val lastResult = it.second + if (disabled) { + stringProvider.get(R.string.auto_checks_disabled) + } else { + val lastCheck = lastResult?.timestampMs ?: currentTimeMillis() + (lastCheck + getCheckIntervalMs()).formatDate() + } + } + } + + // Actions + fun commit(done: () -> Unit) { + scope.launch { + val updatedModel = getUpdatedDbModel() ?: return@launch + isLoading.value = true + + withContext(ioDispatcher) { + database.updateSite(updatedModel) + } + validationManager.scheduleValidation( + site = updatedModel, + rightNow = true, + cancelPrevious = true + ) + + isLoading.value = false + done() + } + } + + fun checkNow() { + val checkModel = site.withStatus( + status = WAITING + ) + setModel(checkModel) + validationManager.scheduleValidation( + site = checkModel, + rightNow = true, + cancelPrevious = true + ) + } + + fun removeSite(done: () -> Unit) { + validationManager.cancelScheduledValidation(site) + notificationManager.cancelStatusNotification(site) + + scope.launch { + isLoading.value = true + withContext(ioDispatcher) { + database.deleteSite(site) + } + isLoading.value = false + done() + } + } + + fun disableSite() { + validationManager.cancelScheduledValidation(site) + notificationManager.cancelStatusNotification(site) + + scope.launch { + isLoading.value = true + val newModel = site.copy( + settings = site.settings!!.copy( + disabled = true + ) + ) + withContext(ioDispatcher) { + database.updateSite(newModel) + } + isLoading.value = false + setModel(newModel) + } + } + + // Utilities + @VisibleForTesting(otherwise = PRIVATE) + fun getCheckIntervalMs(): Long { + val value = checkIntervalValue.value ?: return 0 + val unit = checkIntervalUnit.value ?: return 0 + return value * unit + } + + @VisibleForTesting(otherwise = PRIVATE) + fun getValidationArgs(): String? { + return when (validationMode.value) { + TERM_SEARCH -> validationSearchTerm.value?.trim() + JAVASCRIPT -> validationScript.value?.trim() + else -> null + } + } + + private fun getUpdatedDbModel(): Site? { + val timeout = timeout.value ?: 10_000 + val cleanedTags = tags.value?.split(',')?.joinToString(separator = ",") ?: "" + + val newSettings = site.settings!!.copy( + validationIntervalMs = getCheckIntervalMs(), + validationMode = validationMode.value!!, + validationArgs = getValidationArgs(), + networkTimeout = timeout, + disabled = false, + certificate = certificateUri.value?.toString() + ) + + val retryPolicyTimes = retryPolicyTimes.value ?: 0 + val retryPolicyMinutes = retryPolicyMinutes.value ?: 0 + val retryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) { + if (site.retryPolicy != null) { + // Have existing policy, update it + site.retryPolicy!!.copy( + count = retryPolicyTimes, + minutes = retryPolicyMinutes + ) + } else { + // Create new policy + RetryPolicy( + count = retryPolicyTimes, + minutes = retryPolicyMinutes + ) + } + } else { + // No policy + null + } + + return site.copy( + name = name.value!!.trim(), + tags = cleanedTags, + url = url.value!!.trim(), + settings = newSettings, + retryPolicy = retryPolicy, + headers = headers.value ?: emptyList() + ) + .withStatus(status = WAITING) + } +} diff --git a/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt new file mode 100644 index 0000000..800f235 --- /dev/null +++ b/app/src/main/java/com/afollestad/nocknock/ui/viewsite/ViewSiteViewModelExt.kt @@ -0,0 +1,110 @@ +/** + * Designed and developed by Aidan Follestad (@afollestad) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.afollestad.nocknock.ui.viewsite + +import com.afollestad.nocknock.data.model.RetryPolicy +import com.afollestad.nocknock.data.model.Site +import com.afollestad.nocknock.data.model.Status.WAITING +import com.afollestad.nocknock.data.model.ValidationMode.JAVASCRIPT +import com.afollestad.nocknock.data.model.ValidationMode.TERM_SEARCH +import com.afollestad.nocknock.utilities.ext.DAY +import com.afollestad.nocknock.utilities.ext.HOUR +import com.afollestad.nocknock.utilities.ext.MINUTE +import com.afollestad.nocknock.utilities.ext.WEEK +import kotlin.math.ceil + +fun ViewSiteViewModel.setModel(site: Site) { + val settings = site.settings ?: throw IllegalArgumentException("Settings must be populated!") + this.site = site + + status.value = site.lastResult?.status ?: WAITING + name.value = site.name + tags.value = site.tags + url.value = site.url + timeout.value = settings.networkTimeout + + validationMode.value = settings.validationMode + when (settings.validationMode) { + TERM_SEARCH -> { + validationSearchTerm.value = settings.validationArgs + validationScript.value = null + } + JAVASCRIPT -> { + validationSearchTerm.value = null + validationScript.value = settings.validationArgs + } + else -> { + validationSearchTerm.value = null + validationScript.value = null + } + } + + setCheckInterval(settings.validationIntervalMs) + setRetryPolicy(site.retryPolicy) + headers.value = site.headers + if (settings.certificate == "null") { + certificateUri.value = "" + } else { + certificateUri.value = settings.certificate + } + + this.disabled.value = settings.disabled + this.lastResult.value = site.lastResult +} + +private fun ViewSiteViewModel.setCheckInterval(interval: Long) { + when { + interval >= WEEK -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, WEEK) + checkIntervalUnit.value = WEEK + } + interval >= DAY -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, DAY) + checkIntervalUnit.value = DAY + } + interval >= HOUR -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, HOUR) + checkIntervalUnit.value = HOUR + } + interval >= MINUTE -> { + checkIntervalValue.value = + getIntervalFromUnit(interval, MINUTE) + checkIntervalUnit.value = MINUTE + } + else -> { + checkIntervalValue.value = 0 + checkIntervalUnit.value = MINUTE + } + } +} + +private fun ViewSiteViewModel.setRetryPolicy(policy: RetryPolicy?) { + if (policy == null) return + retryPolicyTimes.value = policy.count + retryPolicyMinutes.value = policy.minutes +} + +private fun getIntervalFromUnit( + millis: Long, + unit: Long +): Int { + val intervalFloat = millis.toFloat() + val byFloat = unit.toFloat() + return ceil(intervalFloat / byFloat).toInt() +} diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml deleted file mode 100644 index da8bafd..0000000 --- a/app/src/main/res/anim/fade_out.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/app/src/main/res/color/unchecked_chip_text.xml b/app/src/main/res/color/unchecked_chip_text.xml new file mode 100644 index 0000000..8e7f4df --- /dev/null +++ b/app/src/main/res/color/unchecked_chip_text.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/checked_chip.xml b/app/src/main/res/drawable/checked_chip.xml new file mode 100644 index 0000000..85010f5 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/checked_chip_pressed.xml b/app/src/main/res/drawable/checked_chip_pressed.xml new file mode 100644 index 0000000..0d7c176 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip_pressed.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/checked_chip_selector.xml b/app/src/main/res/drawable/checked_chip_selector.xml new file mode 100644 index 0000000..fa9df00 --- /dev/null +++ b/app/src/main/res/drawable/checked_chip_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml index 9be83b4..7561a3c 100644 --- a/app/src/main/res/drawable/divider.xml +++ b/app/src/main/res/drawable/divider.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_action_close.xml b/app/src/main/res/drawable/ic_action_close.xml index 369498e..241f9be 100644 --- a/app/src/main/res/drawable/ic_action_close.xml +++ b/app/src/main/res/drawable/ic_action_close.xml @@ -4,6 +4,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> diff --git a/app/src/main/res/drawable/ic_action_delete.xml b/app/src/main/res/drawable/ic_action_delete.xml index 7539f69..901c3e1 100644 --- a/app/src/main/res/drawable/ic_action_delete.xml +++ b/app/src/main/res/drawable/ic_action_delete.xml @@ -4,6 +4,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> diff --git a/app/src/main/res/drawable/ic_action_refresh.xml b/app/src/main/res/drawable/ic_action_refresh.xml index 0240dcb..5175bda 100644 --- a/app/src/main/res/drawable/ic_action_refresh.xml +++ b/app/src/main/res/drawable/ic_action_refresh.xml @@ -4,6 +4,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..00fc15d --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/unchecked_chip.xml b/app/src/main/res/drawable/unchecked_chip.xml new file mode 100644 index 0000000..1864bc5 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/unchecked_chip_pressed.xml b/app/src/main/res/drawable/unchecked_chip_pressed.xml new file mode 100644 index 0000000..c387d70 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip_pressed.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/drawable/unchecked_chip_selector.xml b/app/src/main/res/drawable/unchecked_chip_selector.xml new file mode 100644 index 0000000..ba01f74 --- /dev/null +++ b/app/src/main/res/drawable/unchecked_chip_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_addsite.xml b/app/src/main/res/layout/activity_addsite.xml index 90298cb..80f83da 100644 --- a/app/src/main/res/layout/activity_addsite.xml +++ b/app/src/main/res/layout/activity_addsite.xml @@ -1,7 +1,6 @@ - + @@ -34,66 +25,66 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingBottom="@dimen/content_inset" + android:paddingBottom="@dimen/content_inset_double" android:paddingLeft="@dimen/content_inset" android:paddingRight="@dimen/content_inset" + android:paddingTop="@dimen/content_inset_half" > - + - + - + - - - - - + + + + + - - - + + + + + + + + + + + + + +