Add some UI tests

This commit is contained in:
William Brawner 2022-12-22 22:12:20 -07:00
parent 8a2ae66b2f
commit c1216a58d3
19 changed files with 557 additions and 73 deletions

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="-1446219758">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-1235639767">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="-964027671">
<value>
<AndroidTestResultsTableState>
<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="-797988877">
<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="-725122147">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="20293776">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Pixel_3a_API_31_arm64-v8a" value="120" />
<entry key="Pixel_3a_API_33_arm64-v8a" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map>
</option>
</component>
</project>

View file

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

View file

@ -30,9 +30,13 @@ 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 = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "com.wbrawner.pihelper.HiltTestRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
signingConfig = signingConfigs["debug"] signingConfig = signingConfigs["debug"]
} }
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
signingConfigs { signingConfigs {
create("release") { create("release") {
keyAlias = keystoreProperties["keyAlias"].toString() keyAlias = keystoreProperties["keyAlias"].toString()
@ -81,7 +85,14 @@ dependencies {
implementation("androidx.security:security-crypto:1.0.0-rc01") implementation("androidx.security:security-crypto:1.0.0-rc01")
implementation(libs.preference) implementation(libs.preference)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.mockwebserver)
androidTestImplementation(libs.androidx.test.runner)
androidTestUtil(libs.androidx.test.orchestrator)
androidTestImplementation(libs.test.ext) androidTestImplementation(libs.test.ext)
androidTestImplementation(libs.espresso) androidTestImplementation(libs.espresso)
androidTestImplementation(libs.hilt.android.testing)
kaptAndroidTest(libs.hilt.android.kapt)
androidTestImplementation(libs.compose.test.junit)
debugImplementation(libs.compose.test.manifest)
} }

View file

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

View file

@ -0,0 +1 @@
[]

View file

@ -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
}
}

View file

@ -0,0 +1,3 @@
{
"version": 3
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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<MainActivity>()
@get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this)
@BindValue
@JvmField
val apiService: FakeAPIService = FakeAPIService()
@Test
fun testManualConnectionWithPassword() {
onAddScreen(composeTestRule) {
apiService.server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(context.readAsset("json/version_success.json"))
)
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("version") ?: false)
} onAuthScreen {
verifyConnectionSuccessMessage()
apiService.server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(context.readAsset("json/top_items_success.json"))
)
inputPassword("password")
clickAuthenticateWithPassword()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("topItems") ?: false)
assertEquals(
"113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3",
request?.requestUrl?.queryParameter("auth")
)
}
}
@Test
fun testManualConnectionWithAPIKey() {
onAddScreen(composeTestRule) {
val body = context.readAsset("json/version_success.json")
apiService.server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(body)
)
clearHost()
inputHost("${apiService.hostName}:${apiService.port}")
clickConnect()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("version") ?: false)
} onAuthScreen {
verifyConnectionSuccessMessage()
apiService.server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody(context.readAsset("json/top_items_success.json"))
)
inputAPIKey("113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3")
clickAuthenticateWithAPIKey()
val request = apiService.server.takeRequest(1, TimeUnit.SECONDS)
assertTrue(request?.requestUrl?.queryParameterNames?.contains("topItems") ?: false)
assertEquals(
"113459eb7bb31bddee85ade5230d6ad5d8b2fb52879e00a84ff6ae1067a210d3",
request?.requestUrl?.queryParameter("auth")
)
}
}
}
fun Context.readAsset(path: String) = assets.open(path).bufferedReader().use { it.readText() }

View file

@ -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)
}
}

View file

@ -19,11 +19,12 @@ 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.testTag
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
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.Store import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.DayNightPreview
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.ui.PihelperTheme
import java.net.Inet4Address import java.net.Inet4Address
@ -32,16 +33,21 @@ val emulatorBuildModels = listOf(
"sdk_gphone64_arm64" "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 @Composable
fun AddScreen(store: Store) { fun AddScreen(store: Store) {
val context = LocalContext.current val context = LocalContext.current
AddPiholeForm( AddScreen(
scanNetwork = { scanNetwork = scan@{
// TODO: This needs to go in the Store // TODO: This needs to go in the Store
if (BuildConfig.DEBUG && emulatorBuildModels.contains(Build.MODEL)) { if (BuildConfig.DEBUG && emulatorBuildModels.contains(Build.MODEL)) {
// For emulators, just begin scanning the host machine directly // For emulators, just begin scanning the host machine directly
store.dispatch(Action.Scan("10.0.2.2")) store.dispatch(Action.Scan("10.0.2.2"))
return@AddPiholeForm return@scan
} }
(context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager) (context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)
?.let { connectivityManager -> ?.let { connectivityManager ->
@ -76,7 +82,7 @@ fun AddScreen(store: Store) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddPiholeForm( fun AddScreen(
scanNetwork: () -> Unit, scanNetwork: () -> Unit,
connectToPihole: (String) -> Unit, connectToPihole: (String) -> Unit,
loading: Boolean = false loading: Boolean = false
@ -84,6 +90,7 @@ fun AddPiholeForm(
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") } val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
Column( Column(
modifier = Modifier modifier = Modifier
.testTag(ADD_SCREEN_TAG)
.padding(16.dp) .padding(16.dp)
.fillMaxSize(), .fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -95,7 +102,11 @@ fun AddPiholeForm(
"attempt to find it for you by scanning your network.", "attempt to find it for you by scanning your network.",
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
PrimaryButton(text = "Scan Network", onClick = scanNetwork) PrimaryButton(
modifier = Modifier.testTag(SCAN_BUTTON_TAG),
text = "Scan Network",
onClick = scanNetwork
)
OrDivider() OrDivider()
Text( Text(
text = "If you already know the IP address or host of your Pi-hole, you can also " + 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 textAlign = TextAlign.Center
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.testTag(HOST_TAG)
.fillMaxWidth(),
value = host, value = host,
onValueChange = setHost, onValueChange = setHost,
label = { Text("Pi-hole Host") } 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 @Composable
@Preview @DayNightPreview
fun AddPiholeForm_Preview() { fun AddScreen_Preview() {
PihelperTheme(false) { PihelperTheme {
AddPiholeForm(scanNetwork = {}, {}) AddScreen({}, {})
} }
} }
@Composable @Composable
@Preview @DayNightPreview
fun AddPiholeForm_DarkPreview() {
PihelperTheme(true) {
AddPiholeForm(scanNetwork = {}, {})
}
}
@Composable
@Preview
fun OrDivider_Preview() { fun OrDivider_Preview() {
PihelperTheme(false) { PihelperTheme {
OrDivider()
}
}
@Composable
@Preview
fun OrDivider_DarkPreview() {
PihelperTheme(true) {
OrDivider() OrDivider()
} }
} }

View file

@ -10,7 +10,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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
@ -22,6 +21,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.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
import androidx.compose.ui.text.style.TextAlign 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.Action
import com.wbrawner.pihelper.shared.AuthenticationString import com.wbrawner.pihelper.shared.AuthenticationString
import com.wbrawner.pihelper.shared.Store 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) @OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun AuthScreen(store: Store) { fun AuthScreen(dispatch: (Action) -> Unit) {
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 coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.testTag(AUTH_SCREEN_TAG)
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState), .verticalScroll(scrollState),
@ -51,6 +62,7 @@ fun AuthScreen(store: Store) {
contentDescription = null contentDescription = null
) )
Text( Text(
modifier = Modifier.testTag(SUCCESS_TEXT_TAG),
text = "Pi-helper has successfully connected to your Pi-Hole!", text = "Pi-helper has successfully connected to your Pi-Hole!",
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
@ -60,30 +72,38 @@ fun AuthScreen(store: Store) {
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier modifier = Modifier
.testTag(PASSWORD_INPUT_TAG)
.fillMaxWidth() .fillMaxWidth()
.autofill(listOf(AutofillType.Password), onFill = setPassword), .autofill(listOf(AutofillType.Password), onFill = setPassword),
value = password, value = password,
onValueChange = setPassword, onValueChange = setPassword,
label = { Text("Pi-hole Password") }, label = { Text("Pi-hole Password") },
visualTransformation = PasswordVisualTransformation() visualTransformation = PasswordVisualTransformation(),
maxLines = 1
) )
PrimaryButton(text = "Authenticate with Password") { PrimaryButton(
store.dispatch(Action.Authenticate(AuthenticationString.Password(password))) modifier = Modifier.testTag(PASSWORD_BUTTON_TAG),
text = "Authenticate with Password"
) {
dispatch(Action.Authenticate(AuthenticationString.Password(password)))
} }
OrDivider() OrDivider()
OutlinedTextField( OutlinedTextField(
modifier = Modifier modifier = Modifier
.testTag(API_KEY_INPUT_TAG)
.fillMaxWidth() .fillMaxWidth()
.autofill(listOf(AutofillType.Password), onFill = setApiKey), .autofill(listOf(AutofillType.Password), onFill = setApiKey),
value = apiKey, value = apiKey,
onValueChange = setApiKey, onValueChange = setApiKey,
label = { Text("Pi-hole API Key") }, label = { Text("Pi-hole API Key") },
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
maxLines = 1
) )
PrimaryButton(text = "Authenticate with API Key") { PrimaryButton(
coroutineScope.launch { modifier = Modifier.testTag(API_KEY_BUTTON_TAG),
store.dispatch(Action.Authenticate(AuthenticationString.Token(apiKey))) text = "Authenticate with API Key"
} ) {
dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
} }
} }
} }

View file

@ -136,11 +136,11 @@ 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("Disable for 10 seconds") { disable(10) } PrimaryButton(text = "Disable for 10 seconds") { disable(10) }
PrimaryButton("Disable for 30 seconds") { disable(30) } PrimaryButton(text = "Disable for 30 seconds") { disable(30) }
PrimaryButton("Disable for 5 minutes") { disable(300) } PrimaryButton(text = "Disable for 5 minutes") { disable(300) }
PrimaryButton("Disable for custom time") { setDialogVisible(true) } PrimaryButton(text = "Disable for custom time") { setDialogVisible(true) }
PrimaryButton("Disable permanently") { disable(null) } PrimaryButton(text = "Disable permanently") { disable(null) }
CustomTimeDialog(dialogVisible, setDialogVisible) { CustomTimeDialog(dialogVisible, setDialogVisible) {
disable(it) disable(it)
} }
@ -148,9 +148,13 @@ fun DisableControls(disable: (duration: Long?) -> Unit) {
} }
@Composable @Composable
fun PrimaryButton(text: String, onClick: () -> Unit) { fun PrimaryButton(
modifier: Modifier = Modifier,
text: String,
onClick: () -> Unit
) {
Button( Button(
modifier = Modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary contentColor = MaterialTheme.colorScheme.onPrimary
@ -293,7 +297,7 @@ fun StatusLabelDisabledWithTime_Preview() {
@DayNightPreview @DayNightPreview
fun PrimaryButton_Preview() { fun PrimaryButton_Preview() {
PihelperTheme { PihelperTheme {
PrimaryButton("Disable") {} PrimaryButton(text = "Disable") {}
} }
} }

View file

@ -2,6 +2,8 @@
androidx-core = "1.9.0" androidx-core = "1.9.0"
androidx-appcompat = "1.5.1" androidx-appcompat = "1.5.1"
androidx-splash = "1.0.0" androidx-splash = "1.0.0"
androidx-test-runner = "1.5.1"
androidx-test-orchestrator = "1.4.2"
compose = "1.2.1" compose = "1.2.1"
compose-compiler = "1.3.2" compose-compiler = "1.3.2"
compose-material3 = "1.0.1" compose-material3 = "1.0.1"
@ -16,7 +18,7 @@ material = "1.3.0"
maxSdk = "33" maxSdk = "33"
minSdk = "23" minSdk = "23"
navigation = "2.4.1" navigation = "2.4.1"
okhttp = "4.2.2" okhttp = "4.10.0"
settings = "0.8.1" settings = "0.8.1"
versionCode = "1" versionCode = "1"
versionName = "1.0" 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-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" } 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-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" }
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } 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-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-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui = { module = "androidx.compose.ui:ui", 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" } dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } 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-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" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
junit = { module = "junit:junit", version = "4.12" } junit = { module = "junit:junit", version = "4.12" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 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" } 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" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
material = { module = "com.google.android.material:material", version.ref = "material" } 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" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" } navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui = { module = "androidx.navigation:navigation-ui-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" } 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] [bundles]
compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"] compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"] 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"]

View file

@ -12,22 +12,20 @@ import kotlinx.serialization.json.Json
const val BASE_PATH = "/admin/api.php" const val BASE_PATH = "/admin/api.php"
abstract class PiholeAPIService { interface PiholeAPIService {
abstract var baseUrl: String? var baseUrl: String?
abstract var apiKey: 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 companion object
} }
fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() { fun <T : HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -41,8 +39,16 @@ fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
} }
} }
class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() { class KtorPiholeAPIService(private val httpClient: HttpClient) : PiholeAPIService {
private var port = 80
override var baseUrl: String? = null 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 override var apiKey: String? = null
get() { get() {
println("apiKey: $field") println("apiKey: $field")
@ -52,6 +58,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
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
encodedPath = BASE_PATH encodedPath = BASE_PATH
} }
}.body() }.body()
@ -59,14 +66,17 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
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")
port = this@KtorPiholeAPIService.port
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 {
url { url {
host = baseUrl ?: error("baseUrl not set") host = baseUrl ?: error("baseUrl not set")
port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH encodedPath = BASE_PATH
parameter("topItems", "25") parameter("topItems", "25")
parameter("auth", apiKey) parameter("auth", apiKey)
@ -76,6 +86,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override suspend fun enable(): StatusResponse = httpClient.get { override suspend fun enable(): StatusResponse = httpClient.get {
url { url {
host = baseUrl ?: error("baseUrl not set") host = baseUrl ?: error("baseUrl not set")
port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH encodedPath = BASE_PATH
parameter("enable", "") parameter("enable", "")
parameter("auth", apiKey) parameter("auth", apiKey)
@ -85,6 +96,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override suspend fun disable(duration: Long?): StatusResponse = httpClient.get { override suspend fun disable(duration: Long?): StatusResponse = httpClient.get {
url { url {
host = baseUrl ?: error("baseUrl not set") host = baseUrl ?: error("baseUrl not set")
port = this@KtorPiholeAPIService.port
encodedPath = BASE_PATH encodedPath = BASE_PATH
parameter("disable", duration?.toString() ?: "") parameter("disable", duration?.toString() ?: "")
parameter("auth", apiKey) parameter("auth", apiKey)
@ -94,6 +106,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override suspend fun getDisabledDuration(): Long = httpClient.get { override suspend fun getDisabledDuration(): Long = httpClient.get {
url { url {
host = baseUrl ?: error("baseUrl not set") host = baseUrl ?: error("baseUrl not set")
port = this@KtorPiholeAPIService.port
encodedPath = "/custom_disable_timer" encodedPath = "/custom_disable_timer"
} }
}.body<String>().toLong() }.body<String>().toLong()

View file

@ -92,7 +92,7 @@ class Store(
monitorChanges() monitorChanges()
} else { } else {
launch { 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) _state.value = _state.value.copy(loading = true)
launch { launch {
apiService.baseUrl = host apiService.baseUrl = host
@ -184,10 +184,12 @@ class Store(
) )
} catch (e: Exception) { } catch (e: Exception) {
_state.value = _state.value.copy(loading = false) _state.value = _state.value.copy(loading = false)
if (emitError) {
_effects.emit(Effect.Error(e.message ?: "Failed to connect to $host")) _effects.emit(Effect.Error(e.message ?: "Failed to connect to $host"))
} }
} }
} }
}
private fun authenticate(token: String) { private fun authenticate(token: String) {
_state.value = _state.value.copy(loading = true) _state.value = _state.value.copy(loading = true)