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

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

View file

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

View file

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

View file

@ -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"]

View file

@ -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 <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
fun <T : HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
install(ContentNegotiation) {
json(Json {
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
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<String>().toLong()

View file

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