Add more UI tests, fix shortcuts icon colors, fixes for Pi-hole FTL 5.18 auth requirements, improvements for error message visibility

This commit is contained in:
William Brawner 2022-12-23 19:37:30 -07:00
parent c1216a58d3
commit f2fb3f6c2e
35 changed files with 831 additions and 255 deletions

View file

@ -3,6 +3,19 @@
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="-1681706849">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1446219758">
<value>
<AndroidTestResultsTableState>
@ -16,6 +29,19 @@
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1423466859">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1235639767">
<value>
<AndroidTestResultsTableState>
@ -29,6 +55,32 @@
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1082288520">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1041444055">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-964027671">
<value>
<AndroidTestResultsTableState>
@ -68,6 +120,19 @@
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-255309275">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="20293776">
<value>
<AndroidTestResultsTableState>
@ -82,6 +147,60 @@
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="302221053">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="730894960">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="2A221FDH200B53" value="120" />
<entry key="Duration" value="90" />
<entry key="Google&#10; Pixel 7" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1517452226">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="2038388625">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_3a_API_33_arm64-v8a.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2022-12-21T17:17:23.431789Z" />
</component>
</project>

View file

@ -5,6 +5,10 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />

View file

@ -56,6 +56,11 @@
</map>
</option>
</component>
<component name="EntryPointsManager">
<list size="1">
<item index="0" class="java.lang.String" itemvalue="dagger.hilt.android.testing.BindValue" />
</list>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>

View file

@ -30,7 +30,7 @@ android {
targetSdk = libs.versions.maxSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
testInstrumentationRunner = "com.wbrawner.pihelper.HiltTestRunner"
testInstrumentationRunner = "com.wbrawner.pihelper.util.HiltTestRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
signingConfig = signingConfigs["debug"]
}

View file

@ -0,0 +1,3 @@
{
"status": "disabled"
}

View file

@ -0,0 +1,3 @@
{
"status": "enabled"
}

View file

@ -0,0 +1,38 @@
{
"domains_being_blocked": 166010,
"dns_queries_today": 71567,
"ads_blocked_today": 37254,
"ads_percentage_today": 52.054718,
"unique_domains": 17559,
"queries_forwarded": 29352,
"queries_cached": 4864,
"clients_ever_seen": 6,
"unique_clients": 3,
"dns_queries_all_types": 71567,
"reply_UNKNOWN": 174,
"reply_NODATA": 7023,
"reply_NXDOMAIN": 2006,
"reply_CNAME": 13863,
"reply_IP": 47973,
"reply_DOMAIN": 48,
"reply_RRNAME": 0,
"reply_SERVFAIL": 0,
"reply_REFUSED": 0,
"reply_NOTIMP": 0,
"reply_OTHER": 0,
"reply_DNSSEC": 0,
"reply_NONE": 0,
"reply_BLOB": 480,
"dns_queries_all_replies": 71567,
"privacy_level": 0,
"status": "disabled",
"gravity_last_updated": {
"file_exists": true,
"absolute": 1671361515,
"relative": {
"days": 3,
"hours": 5,
"minutes": 23
}
}
}

View file

@ -0,0 +1 @@
[]

View file

@ -1,16 +0,0 @@
package com.wbrawner.pihelper
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.create
import okhttp3.mockwebserver.MockWebServer
class FakeAPIService(
private val apiService: PiholeAPIService = PiholeAPIService.create()
) : PiholeAPIService by apiService {
val server = MockWebServer().apply {
start()
}
val hostName: String = server.hostName
val port = server.port
}

View file

@ -0,0 +1,91 @@
package com.wbrawner.pihelper
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.State
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.util.FakeAPIService
import com.wbrawner.pihelper.util.onMainScreen
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import dagger.hilt.components.SingletonComponent
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@UninstallModules(PiHelperModule::class)
@HiltAndroidTest
@OptIn(ExperimentalAnimationApi::class)
class MainTests {
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this)
@BindValue
@JvmField
val apiService: FakeAPIService = FakeAPIService()
@Test
fun testDisable() {
onMainScreen(composeTestRule) {
apiService.statusEnabled(context)
apiService.disableSuccess(context)
apiService.statusDisabled(context)
apiService.disabledPermanently()
verifyStatus("Enabled")
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("auth") == true)
clickDisablePermanentlyButton()
verifyStatus("Disabled")
}
}
@Test
fun testEnable() {
onMainScreen(composeTestRule) {
apiService.statusDisabled(context)
apiService.disabledPermanently()
apiService.enableSuccess(context)
apiService.statusEnabled(context)
verifyStatus("Disabled")
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("auth") == true)
clickEnableButton()
verifyStatus("Enabled")
}
}
@Module
@InstallIn(SingletonComponent::class)
inner class TestModule {
@Provides
@Singleton
fun providesPiholeAPIService(): PiholeAPIService = apiService
@Provides
@Singleton
fun providesInitialState(apiService: FakeAPIService): State = State(
apiKey = "key",
host = "${apiService.hostName}:${apiService.port}"
)
@Provides
@Singleton
fun providesStore(
apiService: PiholeAPIService,
initialState: State
): Store = Store(apiService, initialState = initialState)
}
}

View file

@ -0,0 +1,153 @@
package com.wbrawner.pihelper
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.util.FakeAPIService
import com.wbrawner.pihelper.util.onAddScreen
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import dagger.hilt.components.SingletonComponent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@UninstallModules(PiHelperModule::class)
@HiltAndroidTest
@OptIn(ExperimentalAnimationApi::class)
class SetupTests {
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this)
@BindValue
@JvmField
val apiService: FakeAPIService = FakeAPIService()
@Test
fun testSuccessfulConnectionWithPassword() {
onAddScreen(composeTestRule) {
apiService.testConnectionSuccess()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
apiService.server.takeRequest(1, TimeUnit.SECONDS)
} onAuthScreen {
apiService.authenticationSuccess(context)
verifyConnectionSuccessMessage()
inputPassword("password")
clickAuthenticateWithPassword()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("topItems") ?: false)
assertEquals(
"113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3",
request?.requestUrl?.queryParameter("auth")
)
}
}
@Test
fun testSuccessfulConnectionWithAPIKey() {
onAddScreen(composeTestRule) {
apiService.testConnectionSuccess()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
apiService.server.takeRequest(1, TimeUnit.SECONDS)
} onAuthScreen {
apiService.authenticationSuccess(context)
verifyConnectionSuccessMessage()
inputAPIKey("113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3")
clickAuthenticateWithAPIKey()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("topItems") ?: false)
assertEquals(
"113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3",
request?.requestUrl?.queryParameter("auth")
)
}
}
@Test
fun testFailedConnection() {
onAddScreen(composeTestRule) {
clearHost()
inputHost("localhost")
clickConnect()
verifyErrorMessageIsDisplayed("Failed to connect")
}
}
@Test
fun testInvalidHost() {
onAddScreen(composeTestRule) {
apiService.testConnectionFailure()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
verifyErrorMessageIsDisplayed("Host does not appear to be a valid Pi-hole")
}
}
@Test
fun testFailedAuthenticationWithAPIKey() {
onAddScreen(composeTestRule) {
apiService.testConnectionSuccess()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
} onAuthScreen {
apiService.authenticationFailure(context)
verifyConnectionSuccessMessage()
inputAPIKey("113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3")
clickAuthenticateWithAPIKey()
verifyErrorMessageIsDisplayed("Invalid credentials")
}
}
@Test
fun testFailedAuthenticationWithPassword() {
onAddScreen(composeTestRule) {
apiService.testConnectionSuccess()
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
} onAuthScreen {
verifyConnectionSuccessMessage()
apiService.authenticationFailure(context)
inputPassword("password")
clickAuthenticateWithPassword()
verifyErrorMessageIsDisplayed("Invalid credentials")
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class TestModule {
@Binds
@Singleton
abstract fun bindsPiholeAPIService(apiService: FakeAPIService): PiholeAPIService
companion object {
@Provides
@Singleton
fun providesStore(
apiService: PiholeAPIService
): Store = Store(apiService)
}
}
}

View file

@ -1,96 +0,0 @@
package com.wbrawner.pihelper
import android.content.Context
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import okhttp3.mockwebserver.MockResponse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.TimeUnit
@UninstallModules(PiHelperModule::class)
@HiltAndroidTest
@OptIn(ExperimentalAnimationApi::class)
class StartupTests {
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this)
@BindValue
@JvmField
val apiService: FakeAPIService = FakeAPIService()
@Test
fun testManualConnectionWithPassword() {
onAddScreen(composeTestRule) {
apiService.server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(context.readAsset("json/version_success.json"))
)
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("version") ?: false)
} onAuthScreen {
verifyConnectionSuccessMessage()
apiService.server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(context.readAsset("json/top_items_success.json"))
)
inputPassword("password")
clickAuthenticateWithPassword()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("topItems") ?: false)
assertEquals(
"113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3",
request?.requestUrl?.queryParameter("auth")
)
}
}
@Test
fun testManualConnectionWithAPIKey() {
onAddScreen(composeTestRule) {
val body = context.readAsset("json/version_success.json")
apiService.server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body)
)
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("version") ?: false)
} onAuthScreen {
verifyConnectionSuccessMessage()
apiService.server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(context.readAsset("json/top_items_success.json"))
)
inputAPIKey("113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3")
clickAuthenticateWithAPIKey()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("topItems") ?: false)
assertEquals(
"113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3",
request?.requestUrl?.queryParameter("auth")
)
}
}
}
fun Context.readAsset(path: String) = assets.open(path).bufferedReader().use { it.readText() }

View file

@ -1,30 +0,0 @@
package com.wbrawner.pihelper
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.Store
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [PiHelperModule::class]
)
abstract class TestModule {
@Binds
@Singleton
abstract fun bindsPiholeAPIService(apiService: FakeAPIService): PiholeAPIService
companion object {
@Provides
@Singleton
fun providesStore(
apiService: PiholeAPIService,
): Store = Store(apiService)
}
}

View file

@ -1,9 +1,12 @@
package com.wbrawner.pihelper
package com.wbrawner.pihelper.util
import android.content.Context
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.test.platform.app.InstrumentationRegistry
import com.wbrawner.pihelper.ADD_SCREEN_TAG
import com.wbrawner.pihelper.CONNECT_BUTTON_TAG
import com.wbrawner.pihelper.HOST_TAG
fun onAddScreen(testRule: ComposeTestRule, actions: AddScreenRobot.() -> Unit) =
AddScreenRobot(testRule).apply { actions() }
@ -31,4 +34,11 @@ class AddScreenRobot(private val testRule: ComposeTestRule) {
fun clickConnect() = testRule.onNode(hasTestTag(CONNECT_BUTTON_TAG)).performClick()
fun verifyErrorMessageIsDisplayed(message: String) {
testRule.waitUntil(2_000) {
testRule
.onAllNodesWithText(message, substring = true)
.fetchSemanticsNodes().size == 1
}
}
}

View file

@ -1,12 +1,10 @@
package com.wbrawner.pihelper
package com.wbrawner.pihelper.util
import android.content.Context
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.platform.app.InstrumentationRegistry
import com.wbrawner.pihelper.*
class AuthScreenRobot(private val testRule: ComposeTestRule) {
val context: Context = InstrumentationRegistry.getInstrumentation().context
@ -26,11 +24,21 @@ class AuthScreenRobot(private val testRule: ComposeTestRule) {
testRule.onNode(hasTestTag(PASSWORD_INPUT_TAG)).performTextInput(password)
fun clickAuthenticateWithPassword() = testRule.onNode(hasTestTag(PASSWORD_BUTTON_TAG))
.performScrollTo()
.performClick()
fun inputAPIKey(key: String) =
testRule.onNode(hasTestTag(API_KEY_INPUT_TAG)).performTextInput(key)
fun clickAuthenticateWithAPIKey() = testRule.onNode(hasTestTag(API_KEY_BUTTON_TAG))
.performScrollTo()
.performClick()
fun verifyErrorMessageIsDisplayed(message: String) {
testRule.waitUntil(2_000) {
testRule
.onAllNodesWithText(message, substring = true)
.fetchSemanticsNodes().size == 1
}
}
}

View file

@ -0,0 +1,100 @@
package com.wbrawner.pihelper.util
import android.content.Context
import com.wbrawner.pihelper.shared.PiholeAPIService
import com.wbrawner.pihelper.shared.create
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
class FakeAPIService : PiholeAPIService by PiholeAPIService.create() {
val server = MockWebServer().apply {
start()
}
val hostName: String = server.hostName
val port = server.port
fun testConnectionSuccess() {
server.enqueue(
MockResponse().setHeader(
"X-Pi-Hole",
"The Pi-hole Web interface is working!"
)
)
}
fun testConnectionFailure() {
server.enqueue(MockResponse().setResponseCode(204))
}
fun authenticationSuccess(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/top_items_success.json")
)
)
}
fun authenticationFailure(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/top_items_failure.json")
)
)
}
fun statusEnabled(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/summary_enabled.json")
)
)
}
fun statusDisabled(context: Context, duration: Long? = null) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/summary_disabled.json")
)
)
}
fun enableSuccess(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/status_enabled.json")
)
)
}
fun disableSuccess(context: Context) {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(
context.readAsset("json/status_disabled.json")
)
)
}
fun disabledPermanently() {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "text/html")
.setBody("<p>Definitely not a number</p>")
)
}
}
private fun Context.readAsset(path: String) = assets.open(path)
.bufferedReader()
.use { it.readText() }

View file

@ -1,4 +1,4 @@
package com.wbrawner.pihelper
package com.wbrawner.pihelper.util
import android.app.Application
import android.content.Context

View file

@ -0,0 +1,50 @@
package com.wbrawner.pihelper.util
import android.content.Context
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.test.platform.app.InstrumentationRegistry
import com.wbrawner.pihelper.DISABLE_PERMANENT_BUTTON_TAG
import com.wbrawner.pihelper.ENABLE_BUTTON_TAG
import com.wbrawner.pihelper.MAIN_SCREEN_TAG
import com.wbrawner.pihelper.STATUS_TEXT_TAG
fun onMainScreen(testRule: ComposeTestRule, actions: MainScreenRobot.() -> Unit) =
MainScreenRobot(testRule).apply { actions() }
class MainScreenRobot(private val testRule: ComposeTestRule) {
val context: Context = InstrumentationRegistry.getInstrumentation().context
init {
testRule.waitUntil {
testRule
.onAllNodesWithTag(MAIN_SCREEN_TAG)
.fetchSemanticsNodes().size == 1
}
}
infix fun onSettingsScreen(actions: AuthScreenRobot.() -> Unit) = AuthScreenRobot(testRule)
.run {
actions()
}
fun verifyStatus(status: String) {
testRule.waitUntil {
testRule.onAllNodes(hasTestTag(STATUS_TEXT_TAG).and(hasText(status)))
.fetchSemanticsNodes().size == 1
}
}
fun clickEnableButton() = testRule.onNode(hasTestTag(ENABLE_BUTTON_TAG)).performClick()
fun clickDisablePermanentlyButton() = testRule.onNode(hasTestTag(DISABLE_PERMANENT_BUTTON_TAG))
.performClick()
fun verifyErrorMessageIsDisplayed(message: String) {
testRule.waitUntil(2_000) {
testRule
.onAllNodesWithText(message, substring = true)
.fetchSemanticsNodes().size == 1
}
}
}

View file

@ -0,0 +1,2 @@
package com.wbrawner.pihelper.util

View file

@ -11,18 +11,19 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.DayNightPreview
import com.wbrawner.pihelper.ui.PihelperTheme
@ -40,6 +41,7 @@ const val SCAN_BUTTON_TAG = "scanButton"
@Composable
fun AddScreen(store: Store) {
val effect by store.effects.collectAsState(initial = Effect.Empty)
val context = LocalContext.current
AddScreen(
scanNetwork = scan@{
@ -76,18 +78,21 @@ fun AddScreen(store: Store) {
connectToPihole = {
store.dispatch(Action.Connect(it))
},
store.state.value.loading
store.state.value.loading,
error = effect as? Effect.Error
)
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun AddScreen(
scanNetwork: () -> Unit,
connectToPihole: (String) -> Unit,
loading: Boolean = false
loading: Boolean = false,
error: Effect.Error? = null
) {
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
val keyboardController = LocalSoftwareKeyboardController.current
Column(
modifier = Modifier
.testTag(ADD_SCREEN_TAG)
@ -124,8 +129,18 @@ fun AddScreen(
PrimaryButton(
modifier = Modifier.testTag(CONNECT_BUTTON_TAG),
text = "Connect to Pi-hole",
onClick = { connectToPihole(host) }
onClick = {
keyboardController?.hide()
connectToPihole(host)
}
)
error?.let {
Text(
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
text = "Connection failed: ${it.message}"
)
}
}
}
@ -162,7 +177,7 @@ fun OrDivider() {
@DayNightPreview
fun AddScreen_Preview() {
PihelperTheme {
AddScreen({}, {})
AddScreen({}, {}, error = Effect.Error("Something bad happened"))
}
}

View file

@ -5,11 +5,10 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@ -21,6 +20,7 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.AuthenticationString
import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Store
const val AUTH_SCREEN_TAG = "authScreen"
@ -39,12 +40,20 @@ const val API_KEY_BUTTON_TAG = "apiKeyButton"
@Composable
fun AuthScreen(store: Store) {
AuthScreen(dispatch = store::dispatch)
val effect by store.effects.collectAsState(initial = Effect.Empty)
AuthScreen(
dispatch = store::dispatch,
effect as? Effect.Error
)
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AuthScreen(dispatch: (Action) -> Unit) {
fun AuthScreen(
dispatch: (Action) -> Unit,
error: Effect.Error? = null
) {
val keyboardController = LocalSoftwareKeyboardController.current
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
val scrollState = rememberScrollState()
@ -85,6 +94,7 @@ fun AuthScreen(dispatch: (Action) -> Unit) {
modifier = Modifier.testTag(PASSWORD_BUTTON_TAG),
text = "Authenticate with Password"
) {
keyboardController?.hide()
dispatch(Action.Authenticate(AuthenticationString.Password(password)))
}
OrDivider()
@ -103,8 +113,16 @@ fun AuthScreen(dispatch: (Action) -> Unit) {
modifier = Modifier.testTag(API_KEY_BUTTON_TAG),
text = "Authenticate with API Key"
) {
keyboardController?.hide()
dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
}
error?.let {
Text(
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
text = "Authentication failed: ${it.message}"
)
}
}
}

View file

@ -6,7 +6,6 @@ import android.os.Bundle
import android.view.View
import android.view.WindowInsetsController
import android.view.animation.AnticipateInterpolator
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
@ -21,7 +20,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.animation.doOnEnd
@ -71,15 +69,12 @@ class MainActivity : AppCompatActivity() {
navController.navigate(state.route.name)
}
val effect by store.effects.collectAsState(initial = Effect.Empty)
val context = LocalContext.current
LaunchedEffect(effect) {
when (effect) {
is Effect.Error -> Toast.makeText(
context,
(effect as Effect.Error).message,
Toast.LENGTH_SHORT
).show()
is Effect.Exit -> finish()
else -> {
// no-op
}
}
}
PihelperTheme {

View file

@ -12,16 +12,16 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.material3.TopAppBarDefaults.smallTopAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Status
import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.DayNightPreview
@ -29,13 +29,43 @@ import com.wbrawner.pihelper.ui.PihelperTheme
import java.util.*
import kotlin.math.pow
import kotlin.math.roundToLong
import com.wbrawner.pihelper.shared.State as PihelperState
const val MAIN_SCREEN_TAG = "mainScreen"
const val STATUS_TEXT_TAG = "statusText"
const val ENABLE_BUTTON_TAG = "enableButton"
const val DISABLE_TEN_BUTTON_TAG = "disableTenButton"
const val DISABLE_THIRTY_BUTTON_TAG = "disableThirtyButton"
const val DISABLE_FIVE_BUTTON_TAG = "disableFiveButton"
const val DISABLE_CUSTOM_BUTTON_TAG = "disableCustomButton"
const val DISABLE_CUSTOM_INPUT_TAG = "disableCustomInput"
const val DISABLE_CUSTOM_INPUT_SECONDS_TAG = "disableCustomInputSeconds"
const val DISABLE_CUSTOM_INPUT_MINUTES_TAG = "disableCustomInputMinutes"
const val DISABLE_CUSTOM_INPUT_HOURS_TAG = "disableCustomInputHours"
const val DISABLE_CUSTOM_CANCEL_BUTTON_TAG = "disableCustomCancelButton"
const val DISABLE_CUSTOM_SUBMIT_BUTTON_TAG = "disableCustomSubmitButton"
const val DISABLE_PERMANENT_BUTTON_TAG = "disablePermanentButton"
@ExperimentalAnimationApi
@Composable
fun MainScreen(store: Store) {
val state by store.state.collectAsState()
val effect by store.effects.collectAsState(initial = Effect.Empty)
println(effect)
MainScreen(state = state, error = effect as? Effect.Error, dispatch = store::dispatch)
}
@ExperimentalAnimationApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(store: Store) {
val state = store.state.collectAsState()
fun MainScreen(
state: PihelperState,
error: Effect.Error? = null,
dispatch: (Action) -> Unit
) {
Scaffold(
modifier = Modifier.testTag(MAIN_SCREEN_TAG),
topBar = {
TopAppBar(
title = { Text("Pi-helper") },
@ -44,7 +74,7 @@ fun MainScreen(store: Store) {
titleContentColor = MaterialTheme.colorScheme.onBackground
),
actions = {
IconButton(onClick = { store.dispatch(Action.About) }) {
IconButton(onClick = { dispatch(Action.About) }) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
@ -64,19 +94,26 @@ fun MainScreen(store: Store) {
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
val status = state.value.status
LoadingSpinner(state.value.loading)
val status = state.status
LoadingSpinner(state.loading)
if (status != null) {
val enabled = status is Status.Enabled
StatusLabel(status)
AnimatedContent(targetState = enabled, contentAlignment = Alignment.Center) {
if (enabled) {
DisableControls { duration -> store.dispatch(Action.Disable(duration)) }
DisableControls { duration -> dispatch(Action.Disable(duration)) }
} else {
EnableControls { store.dispatch(Action.Enable) }
EnableControls { dispatch(Action.Enable) }
}
}
}
error?.let {
Text(
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
text = "${it.message}"
)
}
}
}
}
@ -97,6 +134,7 @@ fun StatusLabel(status: Status) {
else -> Color(0x00000000)
}
Text(
modifier = Modifier.testTag(STATUS_TEXT_TAG),
color = color,
fontWeight = FontWeight.Bold,
text = status.name.capitalize(Locale.US)
@ -115,6 +153,7 @@ fun StatusLabel(status: Status) {
fun EnableControls(onClick: () -> Unit) {
Button(
modifier = Modifier
.testTag(ENABLE_BUTTON_TAG)
.fillMaxWidth()
.padding(16.dp),
colors = ButtonDefaults.buttonColors(
@ -136,11 +175,26 @@ fun DisableControls(disable: (duration: Long?) -> Unit) {
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
) {
PrimaryButton(text = "Disable for 10 seconds") { disable(10) }
PrimaryButton(text = "Disable for 30 seconds") { disable(30) }
PrimaryButton(text = "Disable for 5 minutes") { disable(300) }
PrimaryButton(text = "Disable for custom time") { setDialogVisible(true) }
PrimaryButton(text = "Disable permanently") { disable(null) }
PrimaryButton(
modifier = Modifier.testTag(DISABLE_TEN_BUTTON_TAG),
text = "Disable for 10 seconds"
) { disable(10) }
PrimaryButton(
modifier = Modifier.testTag(DISABLE_THIRTY_BUTTON_TAG),
text = "Disable for 30 seconds"
) { disable(30) }
PrimaryButton(
modifier = Modifier.testTag(DISABLE_FIVE_BUTTON_TAG),
text = "Disable for 5 minutes"
) { disable(300) }
PrimaryButton(
modifier = Modifier.testTag(DISABLE_CUSTOM_BUTTON_TAG),
text = "Disable for custom time"
) { setDialogVisible(true) }
PrimaryButton(
modifier = Modifier.testTag(DISABLE_PERMANENT_BUTTON_TAG),
text = "Disable permanently"
) { disable(null) }
CustomTimeDialog(dialogVisible, setDialogVisible) {
disable(it)
}
@ -186,16 +240,22 @@ fun CustomTimeDialog(
shape = MaterialTheme.shapes.small,
onDismissRequest = { setVisible(false) },
dismissButton = {
TextButton({ setVisible(false) }) {
TextButton(
modifier = Modifier.testTag(DISABLE_CUSTOM_CANCEL_BUTTON_TAG),
onClick = { setVisible(false) }
) {
Text("Cancel")
}
},
confirmButton = {
TextButton(onClick = {
// TODO: Move this math to the viewmodel or repository
onTimeSelected(time.toLong() * (60.0.pow(duration.ordinal)).roundToLong())
setVisible(false)
}) {
TextButton(
modifier = Modifier.testTag(DISABLE_CUSTOM_SUBMIT_BUTTON_TAG),
onClick = {
// TODO: Move this math to the store
onTimeSelected(time.toLong() * (60.0.pow(duration.ordinal)).roundToLong())
setVisible(false)
}
) {
Text("Disable")
}
},
@ -207,6 +267,7 @@ fun CustomTimeDialog(
verticalArrangement = Arrangement.Center
) {
OutlinedTextField(
modifier = Modifier.testTag(DISABLE_CUSTOM_INPUT_TAG),
value = time,
onValueChange = { setTime(it) },
placeholder = { Text("Time to disable") }
@ -216,16 +277,19 @@ fun CustomTimeDialog(
horizontalArrangement = Arrangement.Center
) {
DurationToggle(
modifier = Modifier.testTag(DISABLE_CUSTOM_INPUT_SECONDS_TAG),
selected = duration == Duration.SECONDS,
onClick = { selectDuration(Duration.SECONDS) },
text = "Secs"
)
DurationToggle(
modifier = Modifier.testTag(DISABLE_CUSTOM_INPUT_MINUTES_TAG),
selected = duration == Duration.MINUTES,
onClick = { selectDuration(Duration.MINUTES) },
text = "Mins"
)
DurationToggle(
modifier = Modifier.testTag(DISABLE_CUSTOM_INPUT_HOURS_TAG),
selected = duration == Duration.HOURS,
onClick = { selectDuration(Duration.HOURS) },
text = "Hours"
@ -237,9 +301,14 @@ fun CustomTimeDialog(
}
@Composable
fun DurationToggle(selected: Boolean, onClick: () -> Unit, text: String) {
fun DurationToggle(
modifier: Modifier = Modifier,
selected: Boolean,
onClick: () -> Unit,
text: String
) {
Row(
modifier = Modifier.selectable(selected = selected, onClick = onClick),
modifier = modifier.selectable(selected = selected, onClick = onClick),
verticalAlignment = Alignment.CenterVertically
) {
Button(

View file

@ -2,7 +2,7 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground>
<inset
android:drawable="@drawable/ic_play_arrow"
android:drawable="@drawable/ic_play_arrow_green"
android:inset="15%" />
</foreground>
<background android:drawable="@color/colorSurface" />

View file

@ -2,7 +2,7 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground>
<inset
android:drawable="@drawable/ic_pause"
android:drawable="@drawable/ic_pause_red"
android:inset="20%" />
</foreground>
<background android:drawable="@color/colorSurface" />

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorRedDark"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
</vector>

View file

@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorGreenDark"
android:fillColor="@color/colorWhite"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/colorGreenDark"
android:pathData="M8,5v14l11,-7z" />
</vector>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="BaseTheme">
<item name="android:statusBarColor">@color/colorTransparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View file

@ -9,7 +9,7 @@
<color name="colorGreenDark">#1B5E20</color>
<color name="colorShortcutBackground">#FFFFFFFF</color>
<color name="colorOnSurface">#000000</color>
<color name="colorWhite">#ffffff</color>
<color name="colorWhite">#FFFBFE</color>
<color name="colorSurface">@color/colorWhite</color>
<color name="colorTransparent">#00000000</color>
</resources>
</resources>

View file

@ -4,7 +4,7 @@
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@drawable/background_splash</item>
<item name="android:statusBarColor">@color/colorTransparent</item>
<item name="android:statusBarColor">@color/colorWhite</item>
</style>
<style name="AppTheme" parent="BaseTheme">

View file

@ -15,6 +15,7 @@ const val BASE_PATH = "/admin/api.php"
interface PiholeAPIService {
var baseUrl: String?
var apiKey: String?
suspend fun testConnection(): Boolean
suspend fun getSummary(): Summary
suspend fun getVersion(): VersionResponse
suspend fun getTopItems(): TopItemsResponse
@ -47,22 +48,33 @@ class KtorPiholeAPIService(private val httpClient: HttpClient) : PiholeAPIServic
val parts = value.split(":")
field = parts.first()
port = parts.last().toInt()
} else {
field = value
}
}
override var apiKey: String? = null
get() {
println("apiKey: $field")
return field
}
override suspend fun getSummary(): Summary = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH
parameter("auth", apiKey)
parameter("summary", "")
}
}.body()
override suspend fun testConnection(): Boolean {
val response = httpClient.head {
url {
host = baseUrl ?: error("baseUrl not set")
port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH
}
}
return response.headers.contains("X-Pi-Hole")
}
override suspend fun getVersion(): VersionResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
@ -70,7 +82,6 @@ class KtorPiholeAPIService(private val httpClient: HttpClient) : PiholeAPIServic
encodedPath = BASE_PATH
parameter("version", "")
}
println("Request sent to $host:$port")
}.body()
override suspend fun getTopItems(): TopItemsResponse = httpClient.get {

View file

@ -3,6 +3,7 @@ package com.wbrawner.pihelper.shared
import com.russhwolf.settings.Settings
import com.russhwolf.settings.get
import com.russhwolf.settings.set
import io.ktor.serialization.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -50,7 +51,7 @@ sealed interface Action {
sealed interface Effect {
object Exit : Effect
data class Error(val message: String) : Effect
object Empty
object Empty : Effect
}
const val KEY_HOST = "baseUrl"
@ -78,8 +79,8 @@ class Store(
println(it)
}
}
val host: String? = settings[KEY_HOST]
val apiKey: String? = settings[KEY_API_KEY]
val host: String? = initialState.host ?: settings[KEY_HOST]
val apiKey: String? = initialState.apiKey ?: settings[KEY_API_KEY]
if (!host.isNullOrBlank() && !apiKey.isNullOrBlank()) {
apiService.baseUrl = host
apiService.apiKey = apiKey
@ -175,7 +176,14 @@ class Store(
launch {
apiService.baseUrl = host
try {
apiService.getVersion()
val isPihole = apiService.testConnection()
if (!isPihole) {
_state.value = _state.value.copy(loading = false)
if (emitError) {
_effects.emit(Effect.Error("Host does not appear to be a valid Pi-hole"))
}
return@launch
}
settings[KEY_HOST] = host
_state.value = _state.value.copy(
host = host,
@ -206,7 +214,12 @@ class Store(
monitorChanges()
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false)
_effects.emit(Effect.Error(e.message ?: "Unable to authenticate with API key"))
val message = if (e is JsonConvertException) {
"Invalid credentials"
} else {
e.message ?: "Unable to authenticate with API key"
}
_effects.emit(Effect.Error(message))
}
}
}
@ -216,6 +229,7 @@ class Store(
_state.value = _state.value.copy(loading = true)
try {
apiService.enable()
getStatus()
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false)
_effects.emit(Effect.Error(e.message ?: "Failed to enable Pi-hole"))
@ -228,6 +242,7 @@ class Store(
_state.value = _state.value.copy(loading = true)
try {
apiService.disable(duration)
getStatus()
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false)
_effects.emit(Effect.Error(e.message ?: "Failed to disable Pi-hole"))
@ -235,35 +250,41 @@ class Store(
}
}
private suspend fun getStatus() {
val loadingJob = coroutineScope {
launch {
delay(1000)
_state.value = _state.value.copy(loading = true)
}
private fun getStatus() {
val loadingJob = launch {
delay(1000)
_state.value = _state.value.copy(loading = true)
}
try {
val summary = apiService.getSummary()
var status = summary.status
if (status is Status.Disabled) {
try {
val until = apiService.getDisabledDuration()
val now = Clock.System.now().toEpochMilliseconds()
if (now > until) return
status = status.copy(timeRemaining = (until - now).toDurationString())
} catch (ignored: Exception) {
// This isn't critical to the operation of the app so errors are unimportant
ignored.printStackTrace()
launch {
try {
val summary = apiService.getSummary()
var status = summary.status
if (status is Status.Disabled) {
try {
val until = apiService.getDisabledDuration()
val now = Clock.System.now().toEpochMilliseconds()
if (now > until) return@launch
status = status.copy(timeRemaining = (until - now).toDurationString())
} catch (e: Exception) {
// This isn't critical to the operation of the app so errors are unimportant
if (e is NumberFormatException) {
// Pi-hole will redirect to /admin instead of just sending a 404 if
// the file isn't present, so it's probably disabled permanently
} else {
_effects.emit(Effect.Error("Failed to determine disabled duration"))
e.printStackTrace()
}
}
}
_effects.emit(Effect.Empty)
_state.value = _state.value.copy(status = status, loading = false)
} catch (e: Exception) {
e.printStackTrace()
_state.value = _state.value.copy(loading = false)
_effects.emit(Effect.Error(e.message ?: "Failed to load status"))
} finally {
loadingJob.cancel("")
}
loadingJob.cancel("")
_state.value = _state.value.copy(status = status, loading = false)
} catch (e: Exception) {
e.printStackTrace()
_state.value = _state.value.copy(loading = false)
_effects.emit(Effect.Error(e.message ?: "Failed to load status"))
} finally {
loadingJob.cancel("")
}
}