Add some UI tests
This commit is contained in:
parent
8a2ae66b2f
commit
c1216a58d3
19 changed files with 557 additions and 73 deletions
88
.idea/androidTestResultsUserPreferences.xml
Normal file
88
.idea/androidTestResultsUserPreferences.xml
Normal 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>
|
17
.idea/deploymentTargetDropDown.xml
Normal file
17
.idea/deploymentTargetDropDown.xml
Normal 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>
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
38
app/src/androidTest/assets/json/status_success.json
Normal file
38
app/src/androidTest/assets/json/status_success.json
Normal file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"domains_being_blocked": 166010,
|
||||
"dns_queries_today": 71567,
|
||||
"ads_blocked_today": 37254,
|
||||
"ads_percentage_today": 52.054718,
|
||||
"unique_domains": 17559,
|
||||
"queries_forwarded": 29352,
|
||||
"queries_cached": 4864,
|
||||
"clients_ever_seen": 6,
|
||||
"unique_clients": 3,
|
||||
"dns_queries_all_types": 71567,
|
||||
"reply_UNKNOWN": 174,
|
||||
"reply_NODATA": 7023,
|
||||
"reply_NXDOMAIN": 2006,
|
||||
"reply_CNAME": 13863,
|
||||
"reply_IP": 47973,
|
||||
"reply_DOMAIN": 48,
|
||||
"reply_RRNAME": 0,
|
||||
"reply_SERVFAIL": 0,
|
||||
"reply_REFUSED": 0,
|
||||
"reply_NOTIMP": 0,
|
||||
"reply_OTHER": 0,
|
||||
"reply_DNSSEC": 0,
|
||||
"reply_NONE": 0,
|
||||
"reply_BLOB": 480,
|
||||
"dns_queries_all_replies": 71567,
|
||||
"privacy_level": 0,
|
||||
"status": "enabled",
|
||||
"gravity_last_updated": {
|
||||
"file_exists": true,
|
||||
"absolute": 1671361515,
|
||||
"relative": {
|
||||
"days": 3,
|
||||
"hours": 5,
|
||||
"minutes": 23
|
||||
}
|
||||
}
|
||||
}%
|
1
app/src/androidTest/assets/json/top_items_failure.json
Normal file
1
app/src/androidTest/assets/json/top_items_failure.json
Normal file
|
@ -0,0 +1 @@
|
|||
[]
|
56
app/src/androidTest/assets/json/top_items_success.json
Normal file
56
app/src/androidTest/assets/json/top_items_success.json
Normal 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
|
||||
}
|
||||
}
|
3
app/src/androidTest/assets/json/version_success.json
Normal file
3
app/src/androidTest/assets/json/version_success.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"version": 3
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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() }
|
30
app/src/androidTest/java/com/wbrawner/pihelper/TestModule.kt
Normal file
30
app/src/androidTest/java/com/wbrawner/pihelper/TestModule.kt
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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") {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue