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:
parent
c1216a58d3
commit
f2fb3f6c2e
35 changed files with 831 additions and 255 deletions
|
@ -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 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>
|
||||
|
|
|
@ -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>
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
3
app/src/androidTest/assets/json/status_disabled.json
Normal file
3
app/src/androidTest/assets/json/status_disabled.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"status": "disabled"
|
||||
}
|
3
app/src/androidTest/assets/json/status_enabled.json
Normal file
3
app/src/androidTest/assets/json/status_enabled.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"status": "enabled"
|
||||
}
|
38
app/src/androidTest/assets/json/summary_disabled.json
Normal file
38
app/src/androidTest/assets/json/summary_disabled.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -35,4 +35,4 @@
|
|||
"minutes": 23
|
||||
}
|
||||
}
|
||||
}%
|
||||
}
|
1
app/src/androidTest/assets/json/summary_failure.json
Normal file
1
app/src/androidTest/assets/json/summary_failure.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
|
@ -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
|
||||
}
|
91
app/src/androidTest/java/com/wbrawner/pihelper/MainTests.kt
Normal file
91
app/src/androidTest/java/com/wbrawner/pihelper/MainTests.kt
Normal 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)
|
||||
}
|
||||
}
|
153
app/src/androidTest/java/com/wbrawner/pihelper/SetupTests.kt
Normal file
153
app/src/androidTest/java/com/wbrawner/pihelper/SetupTests.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() }
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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() }
|
|
@ -1,4 +1,4 @@
|
|||
package com.wbrawner.pihelper
|
||||
package com.wbrawner.pihelper.util
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
package com.wbrawner.pihelper.util
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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" />
|
||||
|
|
9
app/src/main/res/drawable/ic_pause_red.xml
Normal file
9
app/src/main/res/drawable/ic_pause_red.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
9
app/src/main/res/drawable/ic_play_arrow_green.xml
Normal file
9
app/src/main/res/drawable/ic_play_arrow_green.xml
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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("")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue