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()
|
targetSdk = libs.versions.maxSdk.get().toInt()
|
||||||
versionCode = libs.versions.versionCode.get().toInt()
|
versionCode = libs.versions.versionCode.get().toInt()
|
||||||
versionName = libs.versions.versionName.get()
|
versionName = libs.versions.versionName.get()
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "com.wbrawner.pihelper.HiltTestRunner"
|
||||||
|
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
||||||
signingConfig = signingConfigs["debug"]
|
signingConfig = signingConfigs["debug"]
|
||||||
}
|
}
|
||||||
|
testOptions {
|
||||||
|
execution = "ANDROIDX_TEST_ORCHESTRATOR"
|
||||||
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
keyAlias = keystoreProperties["keyAlias"].toString()
|
keyAlias = keystoreProperties["keyAlias"].toString()
|
||||||
|
@ -81,7 +85,14 @@ dependencies {
|
||||||
implementation("androidx.security:security-crypto:1.0.0-rc01")
|
implementation("androidx.security:security-crypto:1.0.0-rc01")
|
||||||
implementation(libs.preference)
|
implementation(libs.preference)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.mockwebserver)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestUtil(libs.androidx.test.orchestrator)
|
||||||
androidTestImplementation(libs.test.ext)
|
androidTestImplementation(libs.test.ext)
|
||||||
androidTestImplementation(libs.espresso)
|
androidTestImplementation(libs.espresso)
|
||||||
|
androidTestImplementation(libs.hilt.android.testing)
|
||||||
|
kaptAndroidTest(libs.hilt.android.kapt)
|
||||||
|
androidTestImplementation(libs.compose.test.junit)
|
||||||
|
debugImplementation(libs.compose.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.wbrawner.pihelper.shared.Action
|
import com.wbrawner.pihelper.shared.Action
|
||||||
import com.wbrawner.pihelper.shared.Store
|
import com.wbrawner.pihelper.shared.Store
|
||||||
|
import com.wbrawner.pihelper.ui.DayNightPreview
|
||||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
|
|
||||||
|
@ -32,16 +33,21 @@ val emulatorBuildModels = listOf(
|
||||||
"sdk_gphone64_arm64"
|
"sdk_gphone64_arm64"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const val ADD_SCREEN_TAG = "addScreen"
|
||||||
|
const val CONNECT_BUTTON_TAG = "connectButton"
|
||||||
|
const val HOST_TAG = "hostInput"
|
||||||
|
const val SCAN_BUTTON_TAG = "scanButton"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddScreen(store: Store) {
|
fun AddScreen(store: Store) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
AddPiholeForm(
|
AddScreen(
|
||||||
scanNetwork = {
|
scanNetwork = scan@{
|
||||||
// TODO: This needs to go in the Store
|
// TODO: This needs to go in the Store
|
||||||
if (BuildConfig.DEBUG && emulatorBuildModels.contains(Build.MODEL)) {
|
if (BuildConfig.DEBUG && emulatorBuildModels.contains(Build.MODEL)) {
|
||||||
// For emulators, just begin scanning the host machine directly
|
// For emulators, just begin scanning the host machine directly
|
||||||
store.dispatch(Action.Scan("10.0.2.2"))
|
store.dispatch(Action.Scan("10.0.2.2"))
|
||||||
return@AddPiholeForm
|
return@scan
|
||||||
}
|
}
|
||||||
(context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)
|
(context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)
|
||||||
?.let { connectivityManager ->
|
?.let { connectivityManager ->
|
||||||
|
@ -76,7 +82,7 @@ fun AddScreen(store: Store) {
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AddPiholeForm(
|
fun AddScreen(
|
||||||
scanNetwork: () -> Unit,
|
scanNetwork: () -> Unit,
|
||||||
connectToPihole: (String) -> Unit,
|
connectToPihole: (String) -> Unit,
|
||||||
loading: Boolean = false
|
loading: Boolean = false
|
||||||
|
@ -84,6 +90,7 @@ fun AddPiholeForm(
|
||||||
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
|
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.testTag(ADD_SCREEN_TAG)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
@ -95,7 +102,11 @@ fun AddPiholeForm(
|
||||||
"attempt to find it for you by scanning your network.",
|
"attempt to find it for you by scanning your network.",
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
PrimaryButton(text = "Scan Network", onClick = scanNetwork)
|
PrimaryButton(
|
||||||
|
modifier = Modifier.testTag(SCAN_BUTTON_TAG),
|
||||||
|
text = "Scan Network",
|
||||||
|
onClick = scanNetwork
|
||||||
|
)
|
||||||
OrDivider()
|
OrDivider()
|
||||||
Text(
|
Text(
|
||||||
text = "If you already know the IP address or host of your Pi-hole, you can also " +
|
text = "If you already know the IP address or host of your Pi-hole, you can also " +
|
||||||
|
@ -103,12 +114,18 @@ fun AddPiholeForm(
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.testTag(HOST_TAG)
|
||||||
|
.fillMaxWidth(),
|
||||||
value = host,
|
value = host,
|
||||||
onValueChange = setHost,
|
onValueChange = setHost,
|
||||||
label = { Text("Pi-hole Host") }
|
label = { Text("Pi-hole Host") }
|
||||||
)
|
)
|
||||||
PrimaryButton(text = "Connect to Pi-hole", onClick = { connectToPihole(host) })
|
PrimaryButton(
|
||||||
|
modifier = Modifier.testTag(CONNECT_BUTTON_TAG),
|
||||||
|
text = "Connect to Pi-hole",
|
||||||
|
onClick = { connectToPihole(host) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,33 +159,17 @@ fun OrDivider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@DayNightPreview
|
||||||
fun AddPiholeForm_Preview() {
|
fun AddScreen_Preview() {
|
||||||
PihelperTheme(false) {
|
PihelperTheme {
|
||||||
AddPiholeForm(scanNetwork = {}, {})
|
AddScreen({}, {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@DayNightPreview
|
||||||
fun AddPiholeForm_DarkPreview() {
|
|
||||||
PihelperTheme(true) {
|
|
||||||
AddPiholeForm(scanNetwork = {}, {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Preview
|
|
||||||
fun OrDivider_Preview() {
|
fun OrDivider_Preview() {
|
||||||
PihelperTheme(false) {
|
PihelperTheme {
|
||||||
OrDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Preview
|
|
||||||
fun OrDivider_DarkPreview() {
|
|
||||||
PihelperTheme(true) {
|
|
||||||
OrDivider()
|
OrDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -10,7 +10,6 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
@ -22,6 +21,7 @@ import androidx.compose.ui.layout.boundsInWindow
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalAutofill
|
import androidx.compose.ui.platform.LocalAutofill
|
||||||
import androidx.compose.ui.platform.LocalAutofillTree
|
import androidx.compose.ui.platform.LocalAutofillTree
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
@ -29,17 +29,28 @@ import androidx.compose.ui.unit.dp
|
||||||
import com.wbrawner.pihelper.shared.Action
|
import com.wbrawner.pihelper.shared.Action
|
||||||
import com.wbrawner.pihelper.shared.AuthenticationString
|
import com.wbrawner.pihelper.shared.AuthenticationString
|
||||||
import com.wbrawner.pihelper.shared.Store
|
import com.wbrawner.pihelper.shared.Store
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
const val AUTH_SCREEN_TAG = "authScreen"
|
||||||
|
const val SUCCESS_TEXT_TAG = "successText"
|
||||||
|
const val PASSWORD_INPUT_TAG = "passwordInput"
|
||||||
|
const val PASSWORD_BUTTON_TAG = "passwordButton"
|
||||||
|
const val API_KEY_INPUT_TAG = "apiKeyInput"
|
||||||
|
const val API_KEY_BUTTON_TAG = "apiKeyButton"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthScreen(store: Store) {
|
||||||
|
AuthScreen(dispatch = store::dispatch)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthScreen(store: Store) {
|
fun AuthScreen(dispatch: (Action) -> Unit) {
|
||||||
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
|
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
|
||||||
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
|
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.testTag(AUTH_SCREEN_TAG)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
|
@ -51,6 +62,7 @@ fun AuthScreen(store: Store) {
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
modifier = Modifier.testTag(SUCCESS_TEXT_TAG),
|
||||||
text = "Pi-helper has successfully connected to your Pi-Hole!",
|
text = "Pi-helper has successfully connected to your Pi-Hole!",
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
@ -60,30 +72,38 @@ fun AuthScreen(store: Store) {
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.testTag(PASSWORD_INPUT_TAG)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.autofill(listOf(AutofillType.Password), onFill = setPassword),
|
.autofill(listOf(AutofillType.Password), onFill = setPassword),
|
||||||
value = password,
|
value = password,
|
||||||
onValueChange = setPassword,
|
onValueChange = setPassword,
|
||||||
label = { Text("Pi-hole Password") },
|
label = { Text("Pi-hole Password") },
|
||||||
visualTransformation = PasswordVisualTransformation()
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
maxLines = 1
|
||||||
)
|
)
|
||||||
PrimaryButton(text = "Authenticate with Password") {
|
PrimaryButton(
|
||||||
store.dispatch(Action.Authenticate(AuthenticationString.Password(password)))
|
modifier = Modifier.testTag(PASSWORD_BUTTON_TAG),
|
||||||
|
text = "Authenticate with Password"
|
||||||
|
) {
|
||||||
|
dispatch(Action.Authenticate(AuthenticationString.Password(password)))
|
||||||
}
|
}
|
||||||
OrDivider()
|
OrDivider()
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.testTag(API_KEY_INPUT_TAG)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.autofill(listOf(AutofillType.Password), onFill = setApiKey),
|
.autofill(listOf(AutofillType.Password), onFill = setApiKey),
|
||||||
value = apiKey,
|
value = apiKey,
|
||||||
onValueChange = setApiKey,
|
onValueChange = setApiKey,
|
||||||
label = { Text("Pi-hole API Key") },
|
label = { Text("Pi-hole API Key") },
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
maxLines = 1
|
||||||
)
|
)
|
||||||
PrimaryButton(text = "Authenticate with API Key") {
|
PrimaryButton(
|
||||||
coroutineScope.launch {
|
modifier = Modifier.testTag(API_KEY_BUTTON_TAG),
|
||||||
store.dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
|
text = "Authenticate with API Key"
|
||||||
}
|
) {
|
||||||
|
dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -136,11 +136,11 @@ fun DisableControls(disable: (duration: Long?) -> Unit) {
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
|
||||||
) {
|
) {
|
||||||
PrimaryButton("Disable for 10 seconds") { disable(10) }
|
PrimaryButton(text = "Disable for 10 seconds") { disable(10) }
|
||||||
PrimaryButton("Disable for 30 seconds") { disable(30) }
|
PrimaryButton(text = "Disable for 30 seconds") { disable(30) }
|
||||||
PrimaryButton("Disable for 5 minutes") { disable(300) }
|
PrimaryButton(text = "Disable for 5 minutes") { disable(300) }
|
||||||
PrimaryButton("Disable for custom time") { setDialogVisible(true) }
|
PrimaryButton(text = "Disable for custom time") { setDialogVisible(true) }
|
||||||
PrimaryButton("Disable permanently") { disable(null) }
|
PrimaryButton(text = "Disable permanently") { disable(null) }
|
||||||
CustomTimeDialog(dialogVisible, setDialogVisible) {
|
CustomTimeDialog(dialogVisible, setDialogVisible) {
|
||||||
disable(it)
|
disable(it)
|
||||||
}
|
}
|
||||||
|
@ -148,9 +148,13 @@ fun DisableControls(disable: (duration: Long?) -> Unit) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PrimaryButton(text: String, onClick: () -> Unit) {
|
fun PrimaryButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
@ -293,7 +297,7 @@ fun StatusLabelDisabledWithTime_Preview() {
|
||||||
@DayNightPreview
|
@DayNightPreview
|
||||||
fun PrimaryButton_Preview() {
|
fun PrimaryButton_Preview() {
|
||||||
PihelperTheme {
|
PihelperTheme {
|
||||||
PrimaryButton("Disable") {}
|
PrimaryButton(text = "Disable") {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
androidx-core = "1.9.0"
|
androidx-core = "1.9.0"
|
||||||
androidx-appcompat = "1.5.1"
|
androidx-appcompat = "1.5.1"
|
||||||
androidx-splash = "1.0.0"
|
androidx-splash = "1.0.0"
|
||||||
|
androidx-test-runner = "1.5.1"
|
||||||
|
androidx-test-orchestrator = "1.4.2"
|
||||||
compose = "1.2.1"
|
compose = "1.2.1"
|
||||||
compose-compiler = "1.3.2"
|
compose-compiler = "1.3.2"
|
||||||
compose-material3 = "1.0.1"
|
compose-material3 = "1.0.1"
|
||||||
|
@ -16,7 +18,7 @@ material = "1.3.0"
|
||||||
maxSdk = "33"
|
maxSdk = "33"
|
||||||
minSdk = "23"
|
minSdk = "23"
|
||||||
navigation = "2.4.1"
|
navigation = "2.4.1"
|
||||||
okhttp = "4.2.2"
|
okhttp = "4.10.0"
|
||||||
settings = "0.8.1"
|
settings = "0.8.1"
|
||||||
versionCode = "1"
|
versionCode = "1"
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
@ -26,17 +28,21 @@ android-gradle = { module = "com.android.tools.build:gradle", version = "7.3.1"
|
||||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||||
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
|
androidx-splash = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splash" }
|
||||||
|
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
||||||
|
androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-test-orchestrator" }
|
||||||
compose-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" }
|
compose-activity = { module = "androidx.activity:activity-compose", version = "1.6.0" }
|
||||||
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
|
compose-material = { module = "androidx.compose.material:material", version.ref = "compose" }
|
||||||
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
|
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
|
||||||
compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" }
|
compose-material3-window = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "compose-material3" }
|
||||||
compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
|
compose-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
|
||||||
|
compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" }
|
||||||
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
|
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
|
||||||
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
|
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
|
||||||
dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
|
dagger-hilt = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt-android" }
|
||||||
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
||||||
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
|
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
|
||||||
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
|
hilt-android-kapt = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt-android" }
|
||||||
|
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt-android" }
|
||||||
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
|
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
|
||||||
junit = { module = "junit:junit", version = "4.12" }
|
junit = { module = "junit:junit", version = "4.12" }
|
||||||
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||||
|
@ -55,16 +61,15 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.
|
||||||
kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
|
kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||||
|
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||||
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
|
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
|
||||||
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
|
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
|
||||||
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
|
navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" }
|
||||||
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
|
navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" }
|
||||||
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
|
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
|
||||||
test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
|
test-ext = { module = "androidx.test.ext:junit", version = "1.1.4" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"]
|
compose = ["compose-ui", "compose-material", "compose-material3", "compose-material3-window", "compose-tooling", "compose-activity", "navigation-compose"]
|
||||||
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
|
coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
|
||||||
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
|
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -12,18 +12,16 @@ import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
const val BASE_PATH = "/admin/api.php"
|
const val BASE_PATH = "/admin/api.php"
|
||||||
|
|
||||||
abstract class PiholeAPIService {
|
interface PiholeAPIService {
|
||||||
abstract var baseUrl: String?
|
var baseUrl: String?
|
||||||
abstract var apiKey: String?
|
var apiKey: String?
|
||||||
|
suspend fun getSummary(): Summary
|
||||||
|
suspend fun getVersion(): VersionResponse
|
||||||
|
suspend fun getTopItems(): TopItemsResponse
|
||||||
|
suspend fun enable(): StatusResponse
|
||||||
|
suspend fun disable(duration: Long? = null): StatusResponse
|
||||||
|
suspend fun getDisabledDuration(): Long
|
||||||
|
|
||||||
abstract suspend fun getSummary(): Summary
|
|
||||||
|
|
||||||
abstract suspend fun getVersion(): VersionResponse
|
|
||||||
abstract suspend fun getTopItems(): TopItemsResponse
|
|
||||||
abstract suspend fun enable(): StatusResponse
|
|
||||||
abstract suspend fun disable(duration: Long? = null): StatusResponse
|
|
||||||
|
|
||||||
abstract suspend fun getDisabledDuration(): Long
|
|
||||||
companion object
|
companion object
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,8 +39,16 @@ fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
class KtorPiholeAPIService(private val httpClient: HttpClient) : PiholeAPIService {
|
||||||
|
private var port = 80
|
||||||
override var baseUrl: String? = null
|
override var baseUrl: String? = null
|
||||||
|
set(value) {
|
||||||
|
if (value?.contains(":") == true) {
|
||||||
|
val parts = value.split(":")
|
||||||
|
field = parts.first()
|
||||||
|
port = parts.last().toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
override var apiKey: String? = null
|
override var apiKey: String? = null
|
||||||
get() {
|
get() {
|
||||||
println("apiKey: $field")
|
println("apiKey: $field")
|
||||||
|
@ -52,6 +58,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
||||||
override suspend fun getSummary(): Summary = httpClient.get {
|
override suspend fun getSummary(): Summary = httpClient.get {
|
||||||
url {
|
url {
|
||||||
host = baseUrl ?: error("baseUrl not set")
|
host = baseUrl ?: error("baseUrl not set")
|
||||||
|
port = this@KtorPiholeAPIService.port
|
||||||
encodedPath = BASE_PATH
|
encodedPath = BASE_PATH
|
||||||
}
|
}
|
||||||
}.body()
|
}.body()
|
||||||
|
@ -59,14 +66,17 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
||||||
override suspend fun getVersion(): VersionResponse = httpClient.get {
|
override suspend fun getVersion(): VersionResponse = httpClient.get {
|
||||||
url {
|
url {
|
||||||
host = baseUrl ?: error("baseUrl not set")
|
host = baseUrl ?: error("baseUrl not set")
|
||||||
|
port = this@KtorPiholeAPIService.port
|
||||||
encodedPath = BASE_PATH
|
encodedPath = BASE_PATH
|
||||||
parameter("version", "")
|
parameter("version", "")
|
||||||
}
|
}
|
||||||
|
println("Request sent to $host:$port")
|
||||||
}.body()
|
}.body()
|
||||||
|
|
||||||
override suspend fun getTopItems(): TopItemsResponse = httpClient.get {
|
override suspend fun getTopItems(): TopItemsResponse = httpClient.get {
|
||||||
url {
|
url {
|
||||||
host = baseUrl ?: error("baseUrl not set")
|
host = baseUrl ?: error("baseUrl not set")
|
||||||
|
port = this@KtorPiholeAPIService.port
|
||||||
encodedPath = BASE_PATH
|
encodedPath = BASE_PATH
|
||||||
parameter("topItems", "25")
|
parameter("topItems", "25")
|
||||||
parameter("auth", apiKey)
|
parameter("auth", apiKey)
|
||||||
|
@ -76,6 +86,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
||||||
override suspend fun enable(): StatusResponse = httpClient.get {
|
override suspend fun enable(): StatusResponse = httpClient.get {
|
||||||
url {
|
url {
|
||||||
host = baseUrl ?: error("baseUrl not set")
|
host = baseUrl ?: error("baseUrl not set")
|
||||||
|
port = this@KtorPiholeAPIService.port
|
||||||
encodedPath = BASE_PATH
|
encodedPath = BASE_PATH
|
||||||
parameter("enable", "")
|
parameter("enable", "")
|
||||||
parameter("auth", apiKey)
|
parameter("auth", apiKey)
|
||||||
|
@ -85,6 +96,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
||||||
override suspend fun disable(duration: Long?): StatusResponse = httpClient.get {
|
override suspend fun disable(duration: Long?): StatusResponse = httpClient.get {
|
||||||
url {
|
url {
|
||||||
host = baseUrl ?: error("baseUrl not set")
|
host = baseUrl ?: error("baseUrl not set")
|
||||||
|
port = this@KtorPiholeAPIService.port
|
||||||
encodedPath = BASE_PATH
|
encodedPath = BASE_PATH
|
||||||
parameter("disable", duration?.toString() ?: "")
|
parameter("disable", duration?.toString() ?: "")
|
||||||
parameter("auth", apiKey)
|
parameter("auth", apiKey)
|
||||||
|
@ -94,6 +106,7 @@ class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
||||||
override suspend fun getDisabledDuration(): Long = httpClient.get {
|
override suspend fun getDisabledDuration(): Long = httpClient.get {
|
||||||
url {
|
url {
|
||||||
host = baseUrl ?: error("baseUrl not set")
|
host = baseUrl ?: error("baseUrl not set")
|
||||||
|
port = this@KtorPiholeAPIService.port
|
||||||
encodedPath = "/custom_disable_timer"
|
encodedPath = "/custom_disable_timer"
|
||||||
}
|
}
|
||||||
}.body<String>().toLong()
|
}.body<String>().toLong()
|
||||||
|
|
|
@ -92,7 +92,7 @@ class Store(
|
||||||
monitorChanges()
|
monitorChanges()
|
||||||
} else {
|
} else {
|
||||||
launch {
|
launch {
|
||||||
connect("pi.hole")
|
connect("pi.hole", false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -170,7 +170,7 @@ class Store(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connect(host: String) {
|
private fun connect(host: String, emitError: Boolean = true) {
|
||||||
_state.value = _state.value.copy(loading = true)
|
_state.value = _state.value.copy(loading = true)
|
||||||
launch {
|
launch {
|
||||||
apiService.baseUrl = host
|
apiService.baseUrl = host
|
||||||
|
@ -184,10 +184,12 @@ class Store(
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = _state.value.copy(loading = false)
|
_state.value = _state.value.copy(loading = false)
|
||||||
|
if (emitError) {
|
||||||
_effects.emit(Effect.Error(e.message ?: "Failed to connect to $host"))
|
_effects.emit(Effect.Error(e.message ?: "Failed to connect to $host"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun authenticate(token: String) {
|
private fun authenticate(token: String) {
|
||||||
_state.value = _state.value.copy(loading = true)
|
_state.value = _state.value.copy(loading = true)
|
||||||
|
|
Loading…
Reference in a new issue