diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 0000000..3b4b0b4
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
new file mode 100644
index 0000000..df1c514
--- /dev/null
+++ b/.idea/deploymentTargetDropDown.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4320af5..712301c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -30,9 +30,13 @@ android {
targetSdk = libs.versions.maxSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunner = "com.wbrawner.pihelper.HiltTestRunner"
+ testInstrumentationRunnerArguments["clearPackageData"] = "true"
signingConfig = signingConfigs["debug"]
}
+ testOptions {
+ execution = "ANDROIDX_TEST_ORCHESTRATOR"
+ }
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"].toString()
@@ -81,7 +85,14 @@ dependencies {
implementation("androidx.security:security-crypto:1.0.0-rc01")
implementation(libs.preference)
testImplementation(libs.junit)
+ androidTestImplementation(libs.mockwebserver)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestUtil(libs.androidx.test.orchestrator)
androidTestImplementation(libs.test.ext)
androidTestImplementation(libs.espresso)
+ androidTestImplementation(libs.hilt.android.testing)
+ kaptAndroidTest(libs.hilt.android.kapt)
+ androidTestImplementation(libs.compose.test.junit)
+ debugImplementation(libs.compose.test.manifest)
}
diff --git a/app/src/androidTest/assets/json/status_success.json b/app/src/androidTest/assets/json/status_success.json
new file mode 100644
index 0000000..6a2c9a1
--- /dev/null
+++ b/app/src/androidTest/assets/json/status_success.json
@@ -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": "enabled",
+ "gravity_last_updated": {
+ "file_exists": true,
+ "absolute": 1671361515,
+ "relative": {
+ "days": 3,
+ "hours": 5,
+ "minutes": 23
+ }
+ }
+}%
\ No newline at end of file
diff --git a/app/src/androidTest/assets/json/top_items_failure.json b/app/src/androidTest/assets/json/top_items_failure.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/app/src/androidTest/assets/json/top_items_failure.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/app/src/androidTest/assets/json/top_items_success.json b/app/src/androidTest/assets/json/top_items_success.json
new file mode 100644
index 0000000..c86fabb
--- /dev/null
+++ b/app/src/androidTest/assets/json/top_items_success.json
@@ -0,0 +1,56 @@
+{
+ "top_queries": {
+ "gateway.fe.apple-dns.net": 647,
+ "lb._dns-sd._udp.0.1.168.192.in-addr.arpa": 390,
+ "github.com": 385,
+ "www.belkin.com": 305,
+ "ip.wbrawner.com": 294,
+ "mask.icloud.com": 292,
+ "23.1.168.192.in-addr.arpa": 245,
+ "weather-data.apple.com": 232,
+ "www.google.com": 224,
+ "ocsp2-lb.apple.com.akadns.net": 202,
+ "www.googleapis.com": 198,
+ "e673.dsce9.akamaiedge.net": 196,
+ "domains.google.com": 194,
+ "c1187123150.ip4-d1b183ab.saasprotection.com": 183,
+ "googlehosted.l.googleusercontent.com": 177,
+ "_dns.resolver.arpa": 177,
+ "www.netgear.com": 175,
+ "dns.google": 174,
+ "play.googleapis.com": 170,
+ "ocsp2.g.aaplimg.com": 167,
+ "time.g.aaplimg.com": 161,
+ "userproxypac.aexp.com": 161,
+ "www.gstatic.com": 160,
+ "nrdp.prod.cloud.netflix.com": 155,
+ "mdw-efz.ms-acdc.office.com": 152
+ },
+ "top_ads": {
+ "api2.branch.io": 4173,
+ "cws.conviva.com": 3019,
+ "scribe.logs.roku.com": 1119,
+ "mobile.pipe.aria.microsoft.com": 1083,
+ "ssl.google-analytics.com": 526,
+ "data.emb-api.com": 325,
+ "api.bugfender.com": 200,
+ "googleads.g.doubleclick.net": 174,
+ "app-measurement.com": 157,
+ "config.emb-api.com": 154,
+ "fls-na.amazon.com": 125,
+ "s.youtube.com": 109,
+ "device-api.urbanairship.com": 106,
+ "mobile-collector.newrelic.com": 95,
+ "incoming.telemetry.mozilla.org": 93,
+ "metrics.icloud.com": 74,
+ "www.googleadservices.com": 63,
+ "nova.collect.igodigital.com": 57,
+ "www.googletagmanager.com": 51,
+ "app.adjust.com": 43,
+ "app.adjust.net.in": 43,
+ "app.adjust.world": 43,
+ "iadsdk.apple.com": 41,
+ "api.apptentive.com": 41,
+ "adservice.google.com": 39
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/json/version_success.json b/app/src/androidTest/assets/json/version_success.json
new file mode 100644
index 0000000..78608c7
--- /dev/null
+++ b/app/src/androidTest/assets/json/version_success.json
@@ -0,0 +1,3 @@
+{
+ "version": 3
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/wbrawner/pihelper/AddScreenRobot.kt b/app/src/androidTest/java/com/wbrawner/pihelper/AddScreenRobot.kt
new file mode 100644
index 0000000..4f6d355
--- /dev/null
+++ b/app/src/androidTest/java/com/wbrawner/pihelper/AddScreenRobot.kt
@@ -0,0 +1,34 @@
+package com.wbrawner.pihelper
+
+import android.content.Context
+import androidx.compose.ui.test.*
+import androidx.compose.ui.test.junit4.ComposeTestRule
+import androidx.test.platform.app.InstrumentationRegistry
+
+fun onAddScreen(testRule: ComposeTestRule, actions: AddScreenRobot.() -> Unit) =
+ AddScreenRobot(testRule).apply { actions() }
+
+class AddScreenRobot(private val testRule: ComposeTestRule) {
+ val context: Context = InstrumentationRegistry.getInstrumentation().context
+
+ init {
+ testRule.waitUntil {
+ testRule
+ .onAllNodesWithTag(ADD_SCREEN_TAG)
+ .fetchSemanticsNodes().size == 1
+ }
+ }
+
+ infix fun onAuthScreen(actions: AuthScreenRobot.() -> Unit) = AuthScreenRobot(testRule).run {
+ actions()
+ }
+
+ fun clearHost() =
+ testRule.onNode(hasTestTag(HOST_TAG)).performTextClearance()
+
+ fun inputHost(host: String) =
+ testRule.onNode(hasTestTag(HOST_TAG)).performTextInput(host)
+
+ fun clickConnect() = testRule.onNode(hasTestTag(CONNECT_BUTTON_TAG)).performClick()
+
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/wbrawner/pihelper/AuthScreenRobot.kt b/app/src/androidTest/java/com/wbrawner/pihelper/AuthScreenRobot.kt
new file mode 100644
index 0000000..401ff8e
--- /dev/null
+++ b/app/src/androidTest/java/com/wbrawner/pihelper/AuthScreenRobot.kt
@@ -0,0 +1,36 @@
+package com.wbrawner.pihelper
+
+import android.content.Context
+import androidx.compose.ui.test.hasTestTag
+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
+
+class AuthScreenRobot(private val testRule: ComposeTestRule) {
+ val context: Context = InstrumentationRegistry.getInstrumentation().context
+
+ init {
+ testRule.waitUntil {
+ testRule
+ .onAllNodesWithTag(AUTH_SCREEN_TAG)
+ .fetchSemanticsNodes().size == 1
+ }
+ }
+
+ fun verifyConnectionSuccessMessage() =
+ testRule.onNode(hasTestTag(SUCCESS_TEXT_TAG)).assertExists()
+
+ fun inputPassword(password: String) =
+ testRule.onNode(hasTestTag(PASSWORD_INPUT_TAG)).performTextInput(password)
+
+ fun clickAuthenticateWithPassword() = testRule.onNode(hasTestTag(PASSWORD_BUTTON_TAG))
+ .performClick()
+
+ fun inputAPIKey(key: String) =
+ testRule.onNode(hasTestTag(API_KEY_INPUT_TAG)).performTextInput(key)
+
+ fun clickAuthenticateWithAPIKey() = testRule.onNode(hasTestTag(API_KEY_BUTTON_TAG))
+ .performClick()
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/wbrawner/pihelper/FakeAPIService.kt b/app/src/androidTest/java/com/wbrawner/pihelper/FakeAPIService.kt
new file mode 100644
index 0000000..aeede78
--- /dev/null
+++ b/app/src/androidTest/java/com/wbrawner/pihelper/FakeAPIService.kt
@@ -0,0 +1,16 @@
+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
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/wbrawner/pihelper/HiltTestRunner.kt b/app/src/androidTest/java/com/wbrawner/pihelper/HiltTestRunner.kt
new file mode 100644
index 0000000..3276f0b
--- /dev/null
+++ b/app/src/androidTest/java/com/wbrawner/pihelper/HiltTestRunner.kt
@@ -0,0 +1,13 @@
+package com.wbrawner.pihelper
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+import dagger.hilt.android.testing.HiltTestApplication
+
+@Suppress("unused")
+class HiltTestRunner : AndroidJUnitRunner() {
+ override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
+ return super.newApplication(cl, HiltTestApplication::class.java.name, context)
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/wbrawner/pihelper/StartupTests.kt b/app/src/androidTest/java/com/wbrawner/pihelper/StartupTests.kt
new file mode 100644
index 0000000..8300226
--- /dev/null
+++ b/app/src/androidTest/java/com/wbrawner/pihelper/StartupTests.kt
@@ -0,0 +1,96 @@
+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()
+
+ @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() }
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/wbrawner/pihelper/TestModule.kt b/app/src/androidTest/java/com/wbrawner/pihelper/TestModule.kt
new file mode 100644
index 0000000..0ab716e
--- /dev/null
+++ b/app/src/androidTest/java/com/wbrawner/pihelper/TestModule.kt
@@ -0,0 +1,30 @@
+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)
+ }
+}
diff --git a/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt b/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt
index 5b5abd6..db3f553 100644
--- a/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt
+++ b/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt
@@ -19,11 +19,12 @@ 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.testTag
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Store
+import com.wbrawner.pihelper.ui.DayNightPreview
import com.wbrawner.pihelper.ui.PihelperTheme
import java.net.Inet4Address
@@ -32,16 +33,21 @@ val emulatorBuildModels = listOf(
"sdk_gphone64_arm64"
)
+const val ADD_SCREEN_TAG = "addScreen"
+const val CONNECT_BUTTON_TAG = "connectButton"
+const val HOST_TAG = "hostInput"
+const val SCAN_BUTTON_TAG = "scanButton"
+
@Composable
fun AddScreen(store: Store) {
val context = LocalContext.current
- AddPiholeForm(
- scanNetwork = {
+ AddScreen(
+ scanNetwork = scan@{
// TODO: This needs to go in the Store
if (BuildConfig.DEBUG && emulatorBuildModels.contains(Build.MODEL)) {
// For emulators, just begin scanning the host machine directly
store.dispatch(Action.Scan("10.0.2.2"))
- return@AddPiholeForm
+ return@scan
}
(context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)
?.let { connectivityManager ->
@@ -76,7 +82,7 @@ fun AddScreen(store: Store) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun AddPiholeForm(
+fun AddScreen(
scanNetwork: () -> Unit,
connectToPihole: (String) -> Unit,
loading: Boolean = false
@@ -84,6 +90,7 @@ fun AddPiholeForm(
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
Column(
modifier = Modifier
+ .testTag(ADD_SCREEN_TAG)
.padding(16.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -95,7 +102,11 @@ fun AddPiholeForm(
"attempt to find it for you by scanning your network.",
textAlign = TextAlign.Center
)
- PrimaryButton(text = "Scan Network", onClick = scanNetwork)
+ PrimaryButton(
+ modifier = Modifier.testTag(SCAN_BUTTON_TAG),
+ text = "Scan Network",
+ onClick = scanNetwork
+ )
OrDivider()
Text(
text = "If you already know the IP address or host of your Pi-hole, you can also " +
@@ -103,12 +114,18 @@ fun AddPiholeForm(
textAlign = TextAlign.Center
)
OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .testTag(HOST_TAG)
+ .fillMaxWidth(),
value = host,
onValueChange = setHost,
label = { Text("Pi-hole Host") }
)
- PrimaryButton(text = "Connect to Pi-hole", onClick = { connectToPihole(host) })
+ PrimaryButton(
+ modifier = Modifier.testTag(CONNECT_BUTTON_TAG),
+ text = "Connect to Pi-hole",
+ onClick = { connectToPihole(host) }
+ )
}
}
@@ -142,33 +159,17 @@ fun OrDivider() {
}
@Composable
-@Preview
-fun AddPiholeForm_Preview() {
- PihelperTheme(false) {
- AddPiholeForm(scanNetwork = {}, {})
+@DayNightPreview
+fun AddScreen_Preview() {
+ PihelperTheme {
+ AddScreen({}, {})
}
}
@Composable
-@Preview
-fun AddPiholeForm_DarkPreview() {
- PihelperTheme(true) {
- AddPiholeForm(scanNetwork = {}, {})
- }
-}
-
-@Composable
-@Preview
+@DayNightPreview
fun OrDivider_Preview() {
- PihelperTheme(false) {
- OrDivider()
- }
-}
-
-@Composable
-@Preview
-fun OrDivider_DarkPreview() {
- PihelperTheme(true) {
+ PihelperTheme {
OrDivider()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt b/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt
index a1f9f54..dd4bf89 100644
--- a/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt
+++ b/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt
@@ -10,7 +10,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -22,6 +21,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.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
@@ -29,17 +29,28 @@ import androidx.compose.ui.unit.dp
import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.AuthenticationString
import com.wbrawner.pihelper.shared.Store
-import kotlinx.coroutines.launch
+
+const val AUTH_SCREEN_TAG = "authScreen"
+const val SUCCESS_TEXT_TAG = "successText"
+const val PASSWORD_INPUT_TAG = "passwordInput"
+const val PASSWORD_BUTTON_TAG = "passwordButton"
+const val API_KEY_INPUT_TAG = "apiKeyInput"
+const val API_KEY_BUTTON_TAG = "apiKeyButton"
+
+@Composable
+fun AuthScreen(store: Store) {
+ AuthScreen(dispatch = store::dispatch)
+}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
-fun AuthScreen(store: Store) {
+fun AuthScreen(dispatch: (Action) -> Unit) {
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
- val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
Column(
modifier = Modifier
+ .testTag(AUTH_SCREEN_TAG)
.padding(16.dp)
.fillMaxSize()
.verticalScroll(scrollState),
@@ -51,6 +62,7 @@ fun AuthScreen(store: Store) {
contentDescription = null
)
Text(
+ modifier = Modifier.testTag(SUCCESS_TEXT_TAG),
text = "Pi-helper has successfully connected to your Pi-Hole!",
textAlign = TextAlign.Center
)
@@ -60,30 +72,38 @@ fun AuthScreen(store: Store) {
)
OutlinedTextField(
modifier = Modifier
+ .testTag(PASSWORD_INPUT_TAG)
.fillMaxWidth()
.autofill(listOf(AutofillType.Password), onFill = setPassword),
value = password,
onValueChange = setPassword,
label = { Text("Pi-hole Password") },
- visualTransformation = PasswordVisualTransformation()
+ visualTransformation = PasswordVisualTransformation(),
+ maxLines = 1
)
- PrimaryButton(text = "Authenticate with Password") {
- store.dispatch(Action.Authenticate(AuthenticationString.Password(password)))
+ PrimaryButton(
+ modifier = Modifier.testTag(PASSWORD_BUTTON_TAG),
+ text = "Authenticate with Password"
+ ) {
+ dispatch(Action.Authenticate(AuthenticationString.Password(password)))
}
OrDivider()
OutlinedTextField(
modifier = Modifier
+ .testTag(API_KEY_INPUT_TAG)
.fillMaxWidth()
.autofill(listOf(AutofillType.Password), onFill = setApiKey),
value = apiKey,
onValueChange = setApiKey,
label = { Text("Pi-hole API Key") },
visualTransformation = PasswordVisualTransformation(),
+ maxLines = 1
)
- PrimaryButton(text = "Authenticate with API Key") {
- coroutineScope.launch {
- store.dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
- }
+ PrimaryButton(
+ modifier = Modifier.testTag(API_KEY_BUTTON_TAG),
+ text = "Authenticate with API Key"
+ ) {
+ dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
}
}
}
diff --git a/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt b/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt
index c15dfeb..698d66d 100644
--- a/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt
+++ b/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt
@@ -136,11 +136,11 @@ fun DisableControls(disable: (duration: Long?) -> Unit) {
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
) {
- PrimaryButton("Disable for 10 seconds") { disable(10) }
- PrimaryButton("Disable for 30 seconds") { disable(30) }
- PrimaryButton("Disable for 5 minutes") { disable(300) }
- PrimaryButton("Disable for custom time") { setDialogVisible(true) }
- PrimaryButton("Disable permanently") { disable(null) }
+ 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) }
CustomTimeDialog(dialogVisible, setDialogVisible) {
disable(it)
}
@@ -148,9 +148,13 @@ fun DisableControls(disable: (duration: Long?) -> Unit) {
}
@Composable
-fun PrimaryButton(text: String, onClick: () -> Unit) {
+fun PrimaryButton(
+ modifier: Modifier = Modifier,
+ text: String,
+ onClick: () -> Unit
+) {
Button(
- modifier = Modifier.fillMaxWidth(),
+ modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
@@ -293,7 +297,7 @@ fun StatusLabelDisabledWithTime_Preview() {
@DayNightPreview
fun PrimaryButton_Preview() {
PihelperTheme {
- PrimaryButton("Disable") {}
+ PrimaryButton(text = "Disable") {}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e5f80f4..1cb9f3a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,6 +2,8 @@
androidx-core = "1.9.0"
androidx-appcompat = "1.5.1"
androidx-splash = "1.0.0"
+androidx-test-runner = "1.5.1"
+androidx-test-orchestrator = "1.4.2"
compose = "1.2.1"
compose-compiler = "1.3.2"
compose-material3 = "1.0.1"
@@ -16,7 +18,7 @@ material = "1.3.0"
maxSdk = "33"
minSdk = "23"
navigation = "2.4.1"
-okhttp = "4.2.2"
+okhttp = "4.10.0"
settings = "0.8.1"
versionCode = "1"
versionName = "1.0"
@@ -26,17 +28,21 @@ android-gradle = { module = "com.android.tools.build:gradle", version = "7.3.1"
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
+androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
+androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" }
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" }
-compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
+compose-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
+compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" }
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
-hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
+hilt-android-kapt = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt-android" }
+hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt-android" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
junit = { module = "junit:junit", version = "4.12" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
@@ -55,16 +61,15 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.
kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
material = { module = "com.google.android.material:material", version.ref = "material" }
+mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
-test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
+test-ext = { module = "androidx.test.ext:junit", version = "1.1.4" }
[bundles]
compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
-plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
-
-
+plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt
index e047706..40e17ae 100644
--- a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt
+++ b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt
@@ -12,22 +12,20 @@ import kotlinx.serialization.json.Json
const val BASE_PATH = "/admin/api.php"
-abstract class PiholeAPIService {
- abstract var baseUrl: String?
- abstract var apiKey: String?
+interface PiholeAPIService {
+ var baseUrl: String?
+ var apiKey: String?
+ suspend fun getSummary(): Summary
+ suspend fun getVersion(): VersionResponse
+ suspend fun getTopItems(): TopItemsResponse
+ suspend fun enable(): StatusResponse
+ suspend fun disable(duration: Long? = null): StatusResponse
+ suspend fun getDisabledDuration(): Long
- abstract suspend fun getSummary(): Summary
-
- abstract suspend fun getVersion(): VersionResponse
- abstract suspend fun getTopItems(): TopItemsResponse
- abstract suspend fun enable(): StatusResponse
- abstract suspend fun disable(duration: Long? = null): StatusResponse
-
- abstract suspend fun getDisabledDuration(): Long
companion object
}
-fun HttpClientConfig.commonConfig() {
+fun HttpClientConfig.commonConfig() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
@@ -41,8 +39,16 @@ fun HttpClientConfig.commonConfig() {
}
}
-class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
+class KtorPiholeAPIService(private val httpClient: HttpClient) : PiholeAPIService {
+ private var port = 80
override var baseUrl: String? = null
+ set(value) {
+ if (value?.contains(":") == true) {
+ val parts = value.split(":")
+ field = parts.first()
+ port = parts.last().toInt()
+ }
+ }
override var apiKey: String? = null
get() {
println("apiKey: $field")
@@ -52,6 +58,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override suspend fun getSummary(): Summary = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
+ port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH
}
}.body()
@@ -59,14 +66,17 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override suspend fun getVersion(): VersionResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
+ port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH
parameter("version", "")
}
+ println("Request sent to $host:$port")
}.body()
override suspend fun getTopItems(): TopItemsResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
+ port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH
parameter("topItems", "25")
parameter("auth", apiKey)
@@ -76,6 +86,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override suspend fun enable(): StatusResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
+ port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH
parameter("enable", "")
parameter("auth", apiKey)
@@ -85,6 +96,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override suspend fun disable(duration: Long?): StatusResponse = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
+ port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH
parameter("disable", duration?.toString() ?: "")
parameter("auth", apiKey)
@@ -94,6 +106,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override suspend fun getDisabledDuration(): Long = httpClient.get {
url {
host = baseUrl ?: error("baseUrl not set")
+ port = this@KtorPiholeAPIService.port
encodedPath = "/custom_disable_timer"
}
}.body().toLong()
diff --git a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt
index 6034867..d9fd936 100644
--- a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt
+++ b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt
@@ -92,7 +92,7 @@ class Store(
monitorChanges()
} else {
launch {
- connect("pi.hole")
+ connect("pi.hole", false)
}
}
}
@@ -170,7 +170,7 @@ class Store(
}
}
- private fun connect(host: String) {
+ private fun connect(host: String, emitError: Boolean = true) {
_state.value = _state.value.copy(loading = true)
launch {
apiService.baseUrl = host
@@ -184,7 +184,9 @@ class Store(
)
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false)
- _effects.emit(Effect.Error(e.message ?: "Failed to connect to $host"))
+ if (emitError) {
+ _effects.emit(Effect.Error(e.message ?: "Failed to connect to $host"))
+ }
}
}
}