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