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">
|
<component name="AndroidTestResultsUserPreferences">
|
||||||
<option name="androidTestResultsTableState">
|
<option name="androidTestResultsTableState">
|
||||||
<map>
|
<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">
|
<entry key="-1446219758">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
@ -16,6 +29,19 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="-1235639767">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
@ -29,6 +55,32 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="-964027671">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
@ -68,6 +120,19 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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">
|
<entry key="20293776">
|
||||||
<value>
|
<value>
|
||||||
<AndroidTestResultsTableState>
|
<AndroidTestResultsTableState>
|
||||||
|
@ -82,6 +147,60 @@
|
||||||
</AndroidTestResultsTableState>
|
</AndroidTestResultsTableState>
|
||||||
</value>
|
</value>
|
||||||
</entry>
|
</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>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</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="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</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">
|
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
|
|
|
@ -56,6 +56,11 @@
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</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">
|
<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" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
</component>
|
</component>
|
||||||
|
|
|
@ -30,7 +30,7 @@ android {
|
||||||
targetSdk = libs.versions.maxSdk.get().toInt()
|
targetSdk = libs.versions.maxSdk.get().toInt()
|
||||||
versionCode = libs.versions.versionCode.get().toInt()
|
versionCode = libs.versions.versionCode.get().toInt()
|
||||||
versionName = libs.versions.versionName.get()
|
versionName = libs.versions.versionName.get()
|
||||||
testInstrumentationRunner = "com.wbrawner.pihelper.HiltTestRunner"
|
testInstrumentationRunner = "com.wbrawner.pihelper.util.HiltTestRunner"
|
||||||
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
||||||
signingConfig = signingConfigs["debug"]
|
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
|
"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 android.content.Context
|
||||||
import androidx.compose.ui.test.*
|
import androidx.compose.ui.test.*
|
||||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
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) =
|
fun onAddScreen(testRule: ComposeTestRule, actions: AddScreenRobot.() -> Unit) =
|
||||||
AddScreenRobot(testRule).apply { actions() }
|
AddScreenRobot(testRule).apply { actions() }
|
||||||
|
@ -31,4 +34,11 @@ class AddScreenRobot(private val testRule: ComposeTestRule) {
|
||||||
|
|
||||||
fun clickConnect() = testRule.onNode(hasTestTag(CONNECT_BUTTON_TAG)).performClick()
|
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 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.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 androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.wbrawner.pihelper.*
|
||||||
|
|
||||||
class AuthScreenRobot(private val testRule: ComposeTestRule) {
|
class AuthScreenRobot(private val testRule: ComposeTestRule) {
|
||||||
val context: Context = InstrumentationRegistry.getInstrumentation().context
|
val context: Context = InstrumentationRegistry.getInstrumentation().context
|
||||||
|
@ -26,11 +24,21 @@ class AuthScreenRobot(private val testRule: ComposeTestRule) {
|
||||||
testRule.onNode(hasTestTag(PASSWORD_INPUT_TAG)).performTextInput(password)
|
testRule.onNode(hasTestTag(PASSWORD_INPUT_TAG)).performTextInput(password)
|
||||||
|
|
||||||
fun clickAuthenticateWithPassword() = testRule.onNode(hasTestTag(PASSWORD_BUTTON_TAG))
|
fun clickAuthenticateWithPassword() = testRule.onNode(hasTestTag(PASSWORD_BUTTON_TAG))
|
||||||
|
.performScrollTo()
|
||||||
.performClick()
|
.performClick()
|
||||||
|
|
||||||
fun inputAPIKey(key: String) =
|
fun inputAPIKey(key: String) =
|
||||||
testRule.onNode(hasTestTag(API_KEY_INPUT_TAG)).performTextInput(key)
|
testRule.onNode(hasTestTag(API_KEY_INPUT_TAG)).performTextInput(key)
|
||||||
|
|
||||||
fun clickAuthenticateWithAPIKey() = testRule.onNode(hasTestTag(API_KEY_BUTTON_TAG))
|
fun clickAuthenticateWithAPIKey() = testRule.onNode(hasTestTag(API_KEY_BUTTON_TAG))
|
||||||
|
.performScrollTo()
|
||||||
.performClick()
|
.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.app.Application
|
||||||
import android.content.Context
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.wbrawner.pihelper.shared.Action
|
import com.wbrawner.pihelper.shared.Action
|
||||||
|
import com.wbrawner.pihelper.shared.Effect
|
||||||
import com.wbrawner.pihelper.shared.Store
|
import com.wbrawner.pihelper.shared.Store
|
||||||
import com.wbrawner.pihelper.ui.DayNightPreview
|
import com.wbrawner.pihelper.ui.DayNightPreview
|
||||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||||
|
@ -40,6 +41,7 @@ const val SCAN_BUTTON_TAG = "scanButton"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddScreen(store: Store) {
|
fun AddScreen(store: Store) {
|
||||||
|
val effect by store.effects.collectAsState(initial = Effect.Empty)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
AddScreen(
|
AddScreen(
|
||||||
scanNetwork = scan@{
|
scanNetwork = scan@{
|
||||||
|
@ -76,18 +78,21 @@ fun AddScreen(store: Store) {
|
||||||
connectToPihole = {
|
connectToPihole = {
|
||||||
store.dispatch(Action.Connect(it))
|
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
|
@Composable
|
||||||
fun AddScreen(
|
fun AddScreen(
|
||||||
scanNetwork: () -> Unit,
|
scanNetwork: () -> Unit,
|
||||||
connectToPihole: (String) -> 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 (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.testTag(ADD_SCREEN_TAG)
|
.testTag(ADD_SCREEN_TAG)
|
||||||
|
@ -124,8 +129,18 @@ fun AddScreen(
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
modifier = Modifier.testTag(CONNECT_BUTTON_TAG),
|
modifier = Modifier.testTag(CONNECT_BUTTON_TAG),
|
||||||
text = "Connect to Pi-hole",
|
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
|
@DayNightPreview
|
||||||
fun AddScreen_Preview() {
|
fun AddScreen_Preview() {
|
||||||
PihelperTheme {
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
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.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalAutofill
|
import androidx.compose.ui.platform.LocalAutofill
|
||||||
import androidx.compose.ui.platform.LocalAutofillTree
|
import androidx.compose.ui.platform.LocalAutofillTree
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.wbrawner.pihelper.shared.Action
|
import com.wbrawner.pihelper.shared.Action
|
||||||
import com.wbrawner.pihelper.shared.AuthenticationString
|
import com.wbrawner.pihelper.shared.AuthenticationString
|
||||||
|
import com.wbrawner.pihelper.shared.Effect
|
||||||
import com.wbrawner.pihelper.shared.Store
|
import com.wbrawner.pihelper.shared.Store
|
||||||
|
|
||||||
const val AUTH_SCREEN_TAG = "authScreen"
|
const val AUTH_SCREEN_TAG = "authScreen"
|
||||||
|
@ -39,12 +40,20 @@ const val API_KEY_BUTTON_TAG = "apiKeyButton"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthScreen(store: Store) {
|
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)
|
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@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 (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
|
||||||
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
|
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
@ -85,6 +94,7 @@ fun AuthScreen(dispatch: (Action) -> Unit) {
|
||||||
modifier = Modifier.testTag(PASSWORD_BUTTON_TAG),
|
modifier = Modifier.testTag(PASSWORD_BUTTON_TAG),
|
||||||
text = "Authenticate with Password"
|
text = "Authenticate with Password"
|
||||||
) {
|
) {
|
||||||
|
keyboardController?.hide()
|
||||||
dispatch(Action.Authenticate(AuthenticationString.Password(password)))
|
dispatch(Action.Authenticate(AuthenticationString.Password(password)))
|
||||||
}
|
}
|
||||||
OrDivider()
|
OrDivider()
|
||||||
|
@ -103,8 +113,16 @@ fun AuthScreen(dispatch: (Action) -> Unit) {
|
||||||
modifier = Modifier.testTag(API_KEY_BUTTON_TAG),
|
modifier = Modifier.testTag(API_KEY_BUTTON_TAG),
|
||||||
text = "Authenticate with API Key"
|
text = "Authenticate with API Key"
|
||||||
) {
|
) {
|
||||||
|
keyboardController?.hide()
|
||||||
dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
|
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.View
|
||||||
import android.view.WindowInsetsController
|
import android.view.WindowInsetsController
|
||||||
import android.view.animation.AnticipateInterpolator
|
import android.view.animation.AnticipateInterpolator
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
|
@ -21,7 +20,6 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.core.animation.doOnEnd
|
import androidx.core.animation.doOnEnd
|
||||||
|
@ -71,15 +69,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
navController.navigate(state.route.name)
|
navController.navigate(state.route.name)
|
||||||
}
|
}
|
||||||
val effect by store.effects.collectAsState(initial = Effect.Empty)
|
val effect by store.effects.collectAsState(initial = Effect.Empty)
|
||||||
val context = LocalContext.current
|
|
||||||
LaunchedEffect(effect) {
|
LaunchedEffect(effect) {
|
||||||
when (effect) {
|
when (effect) {
|
||||||
is Effect.Error -> Toast.makeText(
|
|
||||||
context,
|
|
||||||
(effect as Effect.Error).message,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
is Effect.Exit -> finish()
|
is Effect.Exit -> finish()
|
||||||
|
else -> {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PihelperTheme {
|
PihelperTheme {
|
||||||
|
|
|
@ -12,16 +12,16 @@ import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.TopAppBarDefaults.smallTopAppBarColors
|
import androidx.compose.material3.TopAppBarDefaults.smallTopAppBarColors
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.wbrawner.pihelper.shared.Action
|
import com.wbrawner.pihelper.shared.Action
|
||||||
|
import com.wbrawner.pihelper.shared.Effect
|
||||||
import com.wbrawner.pihelper.shared.Status
|
import com.wbrawner.pihelper.shared.Status
|
||||||
import com.wbrawner.pihelper.shared.Store
|
import com.wbrawner.pihelper.shared.Store
|
||||||
import com.wbrawner.pihelper.ui.DayNightPreview
|
import com.wbrawner.pihelper.ui.DayNightPreview
|
||||||
|
@ -29,13 +29,43 @@ import com.wbrawner.pihelper.ui.PihelperTheme
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.roundToLong
|
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
|
@ExperimentalAnimationApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(store: Store) {
|
fun MainScreen(
|
||||||
val state = store.state.collectAsState()
|
state: PihelperState,
|
||||||
|
error: Effect.Error? = null,
|
||||||
|
dispatch: (Action) -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.testTag(MAIN_SCREEN_TAG),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Pi-helper") },
|
title = { Text("Pi-helper") },
|
||||||
|
@ -44,7 +74,7 @@ fun MainScreen(store: Store) {
|
||||||
titleContentColor = MaterialTheme.colorScheme.onBackground
|
titleContentColor = MaterialTheme.colorScheme.onBackground
|
||||||
),
|
),
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { store.dispatch(Action.About) }) {
|
IconButton(onClick = { dispatch(Action.About) }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Settings,
|
imageVector = Icons.Default.Settings,
|
||||||
contentDescription = "Settings",
|
contentDescription = "Settings",
|
||||||
|
@ -64,19 +94,26 @@ fun MainScreen(store: Store) {
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
val status = state.value.status
|
val status = state.status
|
||||||
LoadingSpinner(state.value.loading)
|
LoadingSpinner(state.loading)
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
val enabled = status is Status.Enabled
|
val enabled = status is Status.Enabled
|
||||||
StatusLabel(status)
|
StatusLabel(status)
|
||||||
AnimatedContent(targetState = enabled, contentAlignment = Alignment.Center) {
|
AnimatedContent(targetState = enabled, contentAlignment = Alignment.Center) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
DisableControls { duration -> store.dispatch(Action.Disable(duration)) }
|
DisableControls { duration -> dispatch(Action.Disable(duration)) }
|
||||||
} else {
|
} 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)
|
else -> Color(0x00000000)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
|
modifier = Modifier.testTag(STATUS_TEXT_TAG),
|
||||||
color = color,
|
color = color,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
text = status.name.capitalize(Locale.US)
|
text = status.name.capitalize(Locale.US)
|
||||||
|
@ -115,6 +153,7 @@ fun StatusLabel(status: Status) {
|
||||||
fun EnableControls(onClick: () -> Unit) {
|
fun EnableControls(onClick: () -> Unit) {
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.testTag(ENABLE_BUTTON_TAG)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
@ -136,11 +175,26 @@ fun DisableControls(disable: (duration: Long?) -> Unit) {
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
|
||||||
) {
|
) {
|
||||||
PrimaryButton(text = "Disable for 10 seconds") { disable(10) }
|
PrimaryButton(
|
||||||
PrimaryButton(text = "Disable for 30 seconds") { disable(30) }
|
modifier = Modifier.testTag(DISABLE_TEN_BUTTON_TAG),
|
||||||
PrimaryButton(text = "Disable for 5 minutes") { disable(300) }
|
text = "Disable for 10 seconds"
|
||||||
PrimaryButton(text = "Disable for custom time") { setDialogVisible(true) }
|
) { disable(10) }
|
||||||
PrimaryButton(text = "Disable permanently") { disable(null) }
|
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) {
|
CustomTimeDialog(dialogVisible, setDialogVisible) {
|
||||||
disable(it)
|
disable(it)
|
||||||
}
|
}
|
||||||
|
@ -186,16 +240,22 @@ fun CustomTimeDialog(
|
||||||
shape = MaterialTheme.shapes.small,
|
shape = MaterialTheme.shapes.small,
|
||||||
onDismissRequest = { setVisible(false) },
|
onDismissRequest = { setVisible(false) },
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton({ setVisible(false) }) {
|
TextButton(
|
||||||
|
modifier = Modifier.testTag(DISABLE_CUSTOM_CANCEL_BUTTON_TAG),
|
||||||
|
onClick = { setVisible(false) }
|
||||||
|
) {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(
|
||||||
// TODO: Move this math to the viewmodel or repository
|
modifier = Modifier.testTag(DISABLE_CUSTOM_SUBMIT_BUTTON_TAG),
|
||||||
onTimeSelected(time.toLong() * (60.0.pow(duration.ordinal)).roundToLong())
|
onClick = {
|
||||||
setVisible(false)
|
// TODO: Move this math to the store
|
||||||
}) {
|
onTimeSelected(time.toLong() * (60.0.pow(duration.ordinal)).roundToLong())
|
||||||
|
setVisible(false)
|
||||||
|
}
|
||||||
|
) {
|
||||||
Text("Disable")
|
Text("Disable")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -207,6 +267,7 @@ fun CustomTimeDialog(
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
modifier = Modifier.testTag(DISABLE_CUSTOM_INPUT_TAG),
|
||||||
value = time,
|
value = time,
|
||||||
onValueChange = { setTime(it) },
|
onValueChange = { setTime(it) },
|
||||||
placeholder = { Text("Time to disable") }
|
placeholder = { Text("Time to disable") }
|
||||||
|
@ -216,16 +277,19 @@ fun CustomTimeDialog(
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
DurationToggle(
|
DurationToggle(
|
||||||
|
modifier = Modifier.testTag(DISABLE_CUSTOM_INPUT_SECONDS_TAG),
|
||||||
selected = duration == Duration.SECONDS,
|
selected = duration == Duration.SECONDS,
|
||||||
onClick = { selectDuration(Duration.SECONDS) },
|
onClick = { selectDuration(Duration.SECONDS) },
|
||||||
text = "Secs"
|
text = "Secs"
|
||||||
)
|
)
|
||||||
DurationToggle(
|
DurationToggle(
|
||||||
|
modifier = Modifier.testTag(DISABLE_CUSTOM_INPUT_MINUTES_TAG),
|
||||||
selected = duration == Duration.MINUTES,
|
selected = duration == Duration.MINUTES,
|
||||||
onClick = { selectDuration(Duration.MINUTES) },
|
onClick = { selectDuration(Duration.MINUTES) },
|
||||||
text = "Mins"
|
text = "Mins"
|
||||||
)
|
)
|
||||||
DurationToggle(
|
DurationToggle(
|
||||||
|
modifier = Modifier.testTag(DISABLE_CUSTOM_INPUT_HOURS_TAG),
|
||||||
selected = duration == Duration.HOURS,
|
selected = duration == Duration.HOURS,
|
||||||
onClick = { selectDuration(Duration.HOURS) },
|
onClick = { selectDuration(Duration.HOURS) },
|
||||||
text = "Hours"
|
text = "Hours"
|
||||||
|
@ -237,9 +301,14 @@ fun CustomTimeDialog(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DurationToggle(selected: Boolean, onClick: () -> Unit, text: String) {
|
fun DurationToggle(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
text: String
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.selectable(selected = selected, onClick = onClick),
|
modifier = modifier.selectable(selected = selected, onClick = onClick),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<foreground>
|
<foreground>
|
||||||
<inset
|
<inset
|
||||||
android:drawable="@drawable/ic_play_arrow"
|
android:drawable="@drawable/ic_play_arrow_green"
|
||||||
android:inset="15%" />
|
android:inset="15%" />
|
||||||
</foreground>
|
</foreground>
|
||||||
<background android:drawable="@color/colorSurface" />
|
<background android:drawable="@color/colorSurface" />
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<foreground>
|
<foreground>
|
||||||
<inset
|
<inset
|
||||||
android:drawable="@drawable/ic_pause"
|
android:drawable="@drawable/ic_pause_red"
|
||||||
android:inset="20%" />
|
android:inset="20%" />
|
||||||
</foreground>
|
</foreground>
|
||||||
<background android:drawable="@color/colorSurface" />
|
<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:viewportWidth="24.0"
|
||||||
android:viewportHeight="24.0">
|
android:viewportHeight="24.0">
|
||||||
<path
|
<path
|
||||||
android:fillColor="@color/colorGreenDark"
|
android:fillColor="@color/colorWhite"
|
||||||
android:pathData="M8,5v14l11,-7z"/>
|
android:pathData="M8,5v14l11,-7z"/>
|
||||||
</vector>
|
</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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="AppTheme" parent="BaseTheme">
|
<style name="AppTheme" parent="BaseTheme">
|
||||||
|
<item name="android:statusBarColor">@color/colorTransparent</item>
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
|
@ -9,7 +9,7 @@
|
||||||
<color name="colorGreenDark">#1B5E20</color>
|
<color name="colorGreenDark">#1B5E20</color>
|
||||||
<color name="colorShortcutBackground">#FFFFFFFF</color>
|
<color name="colorShortcutBackground">#FFFFFFFF</color>
|
||||||
<color name="colorOnSurface">#000000</color>
|
<color name="colorOnSurface">#000000</color>
|
||||||
<color name="colorWhite">#ffffff</color>
|
<color name="colorWhite">#FFFBFE</color>
|
||||||
<color name="colorSurface">@color/colorWhite</color>
|
<color name="colorSurface">@color/colorWhite</color>
|
||||||
<color name="colorTransparent">#00000000</color>
|
<color name="colorTransparent">#00000000</color>
|
||||||
</resources>
|
</resources>
|
|
@ -4,7 +4,7 @@
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
<item name="android:windowBackground">@drawable/background_splash</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>
|
||||||
|
|
||||||
<style name="AppTheme" parent="BaseTheme">
|
<style name="AppTheme" parent="BaseTheme">
|
||||||
|
|
|
@ -15,6 +15,7 @@ const val BASE_PATH = "/admin/api.php"
|
||||||
interface PiholeAPIService {
|
interface PiholeAPIService {
|
||||||
var baseUrl: String?
|
var baseUrl: String?
|
||||||
var apiKey: String?
|
var apiKey: String?
|
||||||
|
suspend fun testConnection(): Boolean
|
||||||
suspend fun getSummary(): Summary
|
suspend fun getSummary(): Summary
|
||||||
suspend fun getVersion(): VersionResponse
|
suspend fun getVersion(): VersionResponse
|
||||||
suspend fun getTopItems(): TopItemsResponse
|
suspend fun getTopItems(): TopItemsResponse
|
||||||
|
@ -47,22 +48,33 @@ class KtorPiholeAPIService(private val httpClient: HttpClient) : PiholeAPIServic
|
||||||
val parts = value.split(":")
|
val parts = value.split(":")
|
||||||
field = parts.first()
|
field = parts.first()
|
||||||
port = parts.last().toInt()
|
port = parts.last().toInt()
|
||||||
|
} else {
|
||||||
|
field = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override var apiKey: String? = null
|
override var apiKey: String? = null
|
||||||
get() {
|
|
||||||
println("apiKey: $field")
|
|
||||||
return field
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getSummary(): Summary = httpClient.get {
|
override suspend fun getSummary(): Summary = httpClient.get {
|
||||||
url {
|
url {
|
||||||
host = baseUrl ?: error("baseUrl not set")
|
host = baseUrl ?: error("baseUrl not set")
|
||||||
port = this@KtorPiholeAPIService.port
|
port = this@KtorPiholeAPIService.port
|
||||||
encodedPath = BASE_PATH
|
encodedPath = BASE_PATH
|
||||||
|
parameter("auth", apiKey)
|
||||||
|
parameter("summary", "")
|
||||||
}
|
}
|
||||||
}.body()
|
}.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 {
|
override suspend fun getVersion(): VersionResponse = httpClient.get {
|
||||||
url {
|
url {
|
||||||
host = baseUrl ?: error("baseUrl not set")
|
host = baseUrl ?: error("baseUrl not set")
|
||||||
|
@ -70,7 +82,6 @@ class KtorPiholeAPIService(private val httpClient: HttpClient) : PiholeAPIServic
|
||||||
encodedPath = BASE_PATH
|
encodedPath = BASE_PATH
|
||||||
parameter("version", "")
|
parameter("version", "")
|
||||||
}
|
}
|
||||||
println("Request sent to $host:$port")
|
|
||||||
}.body()
|
}.body()
|
||||||
|
|
||||||
override suspend fun getTopItems(): TopItemsResponse = httpClient.get {
|
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.Settings
|
||||||
import com.russhwolf.settings.get
|
import com.russhwolf.settings.get
|
||||||
import com.russhwolf.settings.set
|
import com.russhwolf.settings.set
|
||||||
|
import io.ktor.serialization.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
@ -50,7 +51,7 @@ sealed interface Action {
|
||||||
sealed interface Effect {
|
sealed interface Effect {
|
||||||
object Exit : Effect
|
object Exit : Effect
|
||||||
data class Error(val message: String) : Effect
|
data class Error(val message: String) : Effect
|
||||||
object Empty
|
object Empty : Effect
|
||||||
}
|
}
|
||||||
|
|
||||||
const val KEY_HOST = "baseUrl"
|
const val KEY_HOST = "baseUrl"
|
||||||
|
@ -78,8 +79,8 @@ class Store(
|
||||||
println(it)
|
println(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val host: String? = settings[KEY_HOST]
|
val host: String? = initialState.host ?: settings[KEY_HOST]
|
||||||
val apiKey: String? = settings[KEY_API_KEY]
|
val apiKey: String? = initialState.apiKey ?: settings[KEY_API_KEY]
|
||||||
if (!host.isNullOrBlank() && !apiKey.isNullOrBlank()) {
|
if (!host.isNullOrBlank() && !apiKey.isNullOrBlank()) {
|
||||||
apiService.baseUrl = host
|
apiService.baseUrl = host
|
||||||
apiService.apiKey = apiKey
|
apiService.apiKey = apiKey
|
||||||
|
@ -175,7 +176,14 @@ class Store(
|
||||||
launch {
|
launch {
|
||||||
apiService.baseUrl = host
|
apiService.baseUrl = host
|
||||||
try {
|
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
|
settings[KEY_HOST] = host
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
host = host,
|
host = host,
|
||||||
|
@ -206,7 +214,12 @@ class Store(
|
||||||
monitorChanges()
|
monitorChanges()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = _state.value.copy(loading = false)
|
_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)
|
_state.value = _state.value.copy(loading = true)
|
||||||
try {
|
try {
|
||||||
apiService.enable()
|
apiService.enable()
|
||||||
|
getStatus()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = _state.value.copy(loading = false)
|
_state.value = _state.value.copy(loading = false)
|
||||||
_effects.emit(Effect.Error(e.message ?: "Failed to enable Pi-hole"))
|
_effects.emit(Effect.Error(e.message ?: "Failed to enable Pi-hole"))
|
||||||
|
@ -228,6 +242,7 @@ class Store(
|
||||||
_state.value = _state.value.copy(loading = true)
|
_state.value = _state.value.copy(loading = true)
|
||||||
try {
|
try {
|
||||||
apiService.disable(duration)
|
apiService.disable(duration)
|
||||||
|
getStatus()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = _state.value.copy(loading = false)
|
_state.value = _state.value.copy(loading = false)
|
||||||
_effects.emit(Effect.Error(e.message ?: "Failed to disable Pi-hole"))
|
_effects.emit(Effect.Error(e.message ?: "Failed to disable Pi-hole"))
|
||||||
|
@ -235,35 +250,41 @@ class Store(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getStatus() {
|
private fun getStatus() {
|
||||||
val loadingJob = coroutineScope {
|
val loadingJob = launch {
|
||||||
launch {
|
delay(1000)
|
||||||
delay(1000)
|
_state.value = _state.value.copy(loading = true)
|
||||||
_state.value = _state.value.copy(loading = true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
try {
|
launch {
|
||||||
val summary = apiService.getSummary()
|
try {
|
||||||
var status = summary.status
|
val summary = apiService.getSummary()
|
||||||
if (status is Status.Disabled) {
|
var status = summary.status
|
||||||
try {
|
if (status is Status.Disabled) {
|
||||||
val until = apiService.getDisabledDuration()
|
try {
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val until = apiService.getDisabledDuration()
|
||||||
if (now > until) return
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
status = status.copy(timeRemaining = (until - now).toDurationString())
|
if (now > until) return@launch
|
||||||
} catch (ignored: Exception) {
|
status = status.copy(timeRemaining = (until - now).toDurationString())
|
||||||
// This isn't critical to the operation of the app so errors are unimportant
|
} catch (e: Exception) {
|
||||||
ignored.printStackTrace()
|
// 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