Fix issues for iOS

This commit is contained in:
William Brawner 2022-10-18 19:29:37 -06:00
parent 120c155114
commit c40e8f0274
15 changed files with 289 additions and 147 deletions

View file

@ -2,18 +2,35 @@
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> <inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
</profile> </profile>

View file

@ -21,5 +21,10 @@
<option name="name" value="Google" /> <option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" /> <option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository> </remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
</component> </component>
</project> </project>

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
@ -25,12 +26,18 @@ import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.ui.PihelperTheme
import java.net.Inet4Address import java.net.Inet4Address
val emulatorBuildModels = listOf(
"Android SDK built for x86",
"sdk_gphone64_arm64"
)
@Composable @Composable
fun AddScreen(store: Store) { fun AddScreen(store: Store) {
val context = LocalContext.current val context = LocalContext.current
AddPiholeForm( AddPiholeForm(
scanNetwork = { scanNetwork = {
if (BuildConfig.DEBUG && Build.MODEL == "Android SDK built for x86") { // TODO: This needs to go in the Store
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@AddPiholeForm
@ -43,17 +50,21 @@ fun AddScreen(store: Store) {
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
?: false ?: false
} }
.forEach { network -> .mapNotNull { network ->
connectivityManager.getLinkProperties(network) connectivityManager.getLinkProperties(network)
?.linkAddresses ?.linkAddresses
?.filter { !it.address.isLoopbackAddress && it.address is Inet4Address } ?.filter {
!it.address.isLoopbackAddress
&& !it.address.isLinkLocalAddress
&& it.address is Inet4Address
}
?.mapNotNull { it.address.hostAddress } ?.mapNotNull { it.address.hostAddress }
?.forEach { ?.forEach {
store.dispatch(Action.Scan(it)) store.dispatch(Action.Scan(it))
} }
} }
} }
?: Toast.makeText(context, "Failed to scan network", Toast.LENGTH_SHORT).show()
}, },
connectToPihole = { connectToPihole = {
store.dispatch(Action.Connect(it)) store.dispatch(Action.Connect(it))

View file

@ -11,7 +11,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
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.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
@ -21,6 +30,7 @@ import com.wbrawner.pihelper.shared.AuthenticationString
import com.wbrawner.pihelper.shared.Store import com.wbrawner.pihelper.shared.Store
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun AuthScreen(store: Store) { fun AuthScreen(store: Store) {
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") } val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
@ -48,7 +58,9 @@ fun AuthScreen(store: Store) {
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.autofill(listOf(AutofillType.Password), onFill = setPassword),
value = password, value = password,
onValueChange = setPassword, onValueChange = setPassword,
label = { Text("Pi-hole Password") }, label = { Text("Pi-hole Password") },
@ -59,7 +71,9 @@ fun AuthScreen(store: Store) {
} }
OrDivider() OrDivider()
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.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") },
@ -72,3 +86,27 @@ fun AuthScreen(store: Store) {
} }
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.autofill(
autofillTypes: List<AutofillType>,
onFill: ((String) -> Unit),
) = composed {
val autofill = LocalAutofill.current
val autofillNode = AutofillNode(onFill = onFill, autofillTypes = autofillTypes)
LocalAutofillTree.current += autofillNode
this
.onGloballyPositioned {
autofillNode.boundingBox = it.boundsInWindow()
}
.onFocusChanged { focusState ->
autofill?.run {
if (focusState.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
}
}

View file

@ -1,13 +1,15 @@
package com.wbrawner.pihelper package com.wbrawner.pihelper
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.MaterialTheme import androidx.compose.material.*
import androidx.compose.material.Text import androidx.compose.material.icons.Icons
import androidx.compose.material.TextButton import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -17,14 +19,39 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
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.PihelperTheme
@Composable @Composable
fun InfoScreen(store: Store) { fun InfoScreen(store: Store) {
InfoScreen(
onBackClicked = { store.dispatch(Action.Back) },
onForgetPiholeClicked = { store.dispatch(Action.Forget) }
)
}
@Composable
fun InfoScreen(onBackClicked: () -> Unit, onForgetPiholeClicked: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp,
title = { Text("About Pi-helper") },
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Go back")
}
}
)
}
) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(it)
.padding(16.dp) .padding(16.dp)
.fillMaxSize(), .fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@ -32,7 +59,8 @@ fun InfoScreen(store: Store) {
) { ) {
LoadingSpinner() LoadingSpinner()
val message = buildAnnotatedString { val message = buildAnnotatedString {
val text = "Pi-helper was made with ❤ by William Brawner. You can find the source " + val text =
"Pi-helper was made with ❤ by William Brawner. You can find the source " +
"code or report issues on the GitHub page for the project." "code or report issues on the GitHub page for the project."
val name = text.indexOf("William") val name = text.indexOf("William")
val github = text.indexOf("GitHub") val github = text.indexOf("GitHub")
@ -69,14 +97,27 @@ fun InfoScreen(store: Store) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
ClickableText( ClickableText(
text = message, text = message,
style = TextStyle.Default.copy(textAlign = TextAlign.Center) style = TextStyle.Default.copy(
color = MaterialTheme.colors.onSurface,
textAlign = TextAlign.Center
),
) { ) {
message.getStringAnnotations(it, it).firstOrNull()?.let { annotation -> message.getStringAnnotations(it, it).firstOrNull()?.let { annotation ->
uriHandler.openUri(annotation.item) uriHandler.openUri(annotation.item)
} }
} }
TextButton(onClick = { store.dispatch(Action.Forget) }) { TextButton(onClick = onForgetPiholeClicked) {
Text(text = "Forget Pi-hole") Text(text = "Forget Pi-hole")
} }
} }
}
}
@Composable
@Preview(showSystemUi = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showSystemUi = true, uiMode = UI_MODE_NIGHT_YES)
fun InfoScreen_Preview() {
PihelperTheme {
InfoScreen({}, {})
}
} }

View file

@ -33,6 +33,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.wbrawner.pihelper.shared.Action import com.wbrawner.pihelper.shared.Action
import com.wbrawner.pihelper.shared.Effect import com.wbrawner.pihelper.shared.Effect
import com.wbrawner.pihelper.shared.Route
import com.wbrawner.pihelper.shared.Store import com.wbrawner.pihelper.shared.Store
import com.wbrawner.pihelper.ui.PihelperTheme import com.wbrawner.pihelper.ui.PihelperTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -66,29 +67,9 @@ class MainActivity : AppCompatActivity() {
} }
val state by store.state.collectAsState() val state by store.state.collectAsState()
val navController = rememberNavController() val navController = rememberNavController()
val startDestination = when { LaunchedEffect(state.route) {
state.host == null -> { println("navigating to ${state.route.name}")
Screens.ADD navController.navigate(state.route.name)
}
state.apiKey == null -> {
Screens.AUTH
}
else -> {
Screens.MAIN
}
}
LaunchedEffect(state) {
if (!state.scanning.isNullOrBlank()) {
navController.navigateIfNotAlreadyThere(Screens.SCAN.route)
} else if (state.host == null) {
navController.navigateIfNotAlreadyThere(Screens.ADD.route)
} else if (state.apiKey == null) {
navController.navigateIfNotAlreadyThere(Screens.AUTH.route)
} else if (state.showAbout) {
navController.navigateIfNotAlreadyThere(Screens.INFO.route)
} else if (state.status != null) {
navController.navigateIfNotAlreadyThere(Screens.MAIN.route)
}
} }
val effect by store.effects.collectAsState(initial = Effect.Empty) val effect by store.effects.collectAsState(initial = Effect.Empty)
val context = LocalContext.current val context = LocalContext.current
@ -103,20 +84,20 @@ class MainActivity : AppCompatActivity() {
} }
} }
PihelperTheme { PihelperTheme {
NavHost(navController, startDestination = startDestination.route) { NavHost(navController, startDestination = state.initialRoute.name) {
composable(Screens.ADD.route) { composable(Route.CONNECT.name) {
AddScreen(store) AddScreen(store)
} }
composable(Screens.SCAN.route) { composable(Route.SCAN.name) {
ScanScreen(store) ScanScreen(store)
} }
composable(Screens.AUTH.route) { composable(Route.AUTH.name) {
AuthScreen(store) AuthScreen(store)
} }
composable(Screens.MAIN.route) { composable(Route.HOME.name) {
MainScreen(store) MainScreen(store)
} }
composable(Screens.INFO.route) { composable(Route.ABOUT.name) {
InfoScreen(store) InfoScreen(store)
} }
} }
@ -146,14 +127,6 @@ class MainActivity : AppCompatActivity() {
} }
} }
enum class Screens(val route: String) {
ADD("add"),
SCAN("scan"),
AUTH("auth"),
MAIN("main"),
INFO("info"),
}
@Composable @Composable
fun LoadingSpinner(animate: Boolean = false) { fun LoadingSpinner(animate: Boolean = false) {
val animation = rememberInfiniteTransition() val animation = rememberInfiniteTransition()

View file

@ -1,6 +1,6 @@
package com.wbrawner.pihelper package com.wbrawner.pihelper
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -59,7 +59,7 @@ fun MainScreen(store: Store) {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
LoadingSpinner(state.value.loading) LoadingSpinner(state.value.loading)
AnimatedVisibility(visible = !state.value.loading) { AnimatedContent(targetState = state.value.status, contentAlignment = Alignment.Center) {
state.value.status?.let { state.value.status?.let {
StatusLabel(it) StatusLabel(it)
if (it == Status.ENABLED) { if (it == Status.ENABLED) {

View file

@ -8,3 +8,10 @@ buildscript {
classpath(libs.bundles.plugins) classpath(libs.bundles.plugins)
} }
} }
allprojects {
repositories {
google()
mavenCentral()
}
}

View file

@ -6,7 +6,7 @@ compose = "1.1.1"
coroutines = "1.4.3" coroutines = "1.4.3"
espresso = "3.3.0" espresso = "3.3.0"
hilt-android = "2.38.1" hilt-android = "2.38.1"
kotlin = "1.6.10" kotlin = "1.7.20"
kotlinx-serialization = "1.3.2" kotlinx-serialization = "1.3.2"
kotlinx-coroutines = "1.6.0-native-mt" kotlinx-coroutines = "1.6.0-native-mt"
ktor = "2.0.0-beta-1" ktor = "2.0.0-beta-1"
@ -51,6 +51,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
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" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
@ -65,6 +66,6 @@ test-ext = { module = "androidx.test.ext:junit", version = "1.1.2" }
[bundles] [bundles]
compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"] compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["coroutines-core", "coroutines-android"] coroutines = ["coroutines-core", "coroutines-android"]
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt"] plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip

View file

@ -1,11 +1,4 @@
enableFeaturePreview("VERSION_CATALOGS") enableFeaturePreview("VERSION_CATALOGS")
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Pi-helper" rootProject.name = "Pi-helper"
include(":app") include(":app")
include(":shared") include(":shared")

View file

@ -1,15 +1,12 @@
plugins { plugins {
kotlin("multiplatform") kotlin("multiplatform")
id("com.android.library") id("com.android.library")
kotlin("plugin.serialization") version "1.6.10" kotlin("plugin.serialization")
} }
kotlin { kotlin {
android() android()
val iosX64 = iosX64() listOf(iosArm64(), iosSimulatorArm64()).forEach {
val iosArm64 = iosArm64()
val iosSimulatorArm64 = iosSimulatorArm64()
listOf(iosX64, iosArm64, iosSimulatorArm64).forEach {
it.binaries.framework { it.binaries.framework {
baseName = "Pihelper" baseName = "Pihelper"
} }
@ -34,12 +31,10 @@ kotlin {
} }
} }
val iosX64Main by getting
val iosArm64Main by getting val iosArm64Main by getting
val iosSimulatorArm64Main by getting val iosSimulatorArm64Main by getting
val iosMain by creating { val iosMain by creating {
dependsOn(commonMain) dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this) iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this) iosSimulatorArm64Main.dependsOn(this)
dependencies { dependencies {

View file

@ -33,15 +33,19 @@ fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
}) })
} }
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 500 requestTimeoutMillis = 1000
connectTimeoutMillis = 500 connectTimeoutMillis = 1000
socketTimeoutMillis = 500 socketTimeoutMillis = 1000
} }
} }
class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() { class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
override var baseUrl: String? = null override var baseUrl: String? = null
override var apiKey: String? = null override var apiKey: String? = null
get() {
println("apiKey: $field")
return field
}
override suspend fun getSummary(): Summary = httpClient.get { override suspend fun getSummary(): Summary = httpClient.get {
url { url {

View file

@ -9,13 +9,22 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
enum class Route(val path: String) {
CONNECT("connect"),
SCAN("scan"),
AUTH("auth"),
HOME("home"),
ABOUT("about"),
}
data class State( data class State(
val apiKey: String? = null, val apiKey: String? = null,
val host: String? = null, val host: String? = null,
val status: Status? = null, val status: Status? = null,
val scanning: String? = null, val scanning: String? = null,
val loading: Boolean = false, val loading: Boolean = false,
val showAbout: Boolean = false, val route: Route = Route.CONNECT,
val initialRoute: Route = Route.CONNECT
) )
sealed interface AuthenticationString { sealed interface AuthenticationString {
@ -70,7 +79,9 @@ class Store(
apiService.apiKey = apiKey apiService.apiKey = apiKey
_state.value = initialState.copy( _state.value = initialState.copy(
host = host, host = host,
apiKey = apiKey apiKey = apiKey,
route = Route.HOME,
initialRoute = Route.HOME
) )
monitorChanges() monitorChanges()
} else { } else {
@ -81,6 +92,7 @@ class Store(
} }
fun dispatch(action: Action) { fun dispatch(action: Action) {
println(action)
when (action) { when (action) {
is Action.Authenticate -> { is Action.Authenticate -> {
when (action.authString) { when (action.authString) {
@ -96,31 +108,28 @@ class Store(
Action.Enable -> enable() Action.Enable -> enable()
Action.Forget -> forget() Action.Forget -> forget()
is Action.Scan -> scan(action.deviceIp) is Action.Scan -> scan(action.deviceIp)
Action.About -> _state.value = _state.value.copy(showAbout = true) Action.About -> _state.value = _state.value.copy(route = Route.ABOUT)
Action.Back -> back() Action.Back -> back()
} }
} }
private fun back() { private fun back() {
when { when (_state.value.route) {
_state.value.showAbout -> { Route.ABOUT -> {
_state.value = _state.value.copy(showAbout = false) _state.value = _state.value.copy(route = Route.HOME)
} }
_state.value.status != null -> { Route.AUTH -> {
_state.value = _state.value.copy(apiKey = null, route = Route.CONNECT)
}
Route.HOME, Route.CONNECT -> {
launch { launch {
_effects.emit(Effect.Exit) _effects.emit(Effect.Exit)
} }
} }
_state.value.scanning != null -> { Route.SCAN -> {
_state.value = _state.value.copy(scanning = null)
scanJob?.cancel("") scanJob?.cancel("")
scanJob = null scanJob = null
} _state.value = _state.value.copy(scanning = null, route = Route.CONNECT)
_state.value.apiKey != null -> {
_state.value = _state.value.copy(apiKey = null)
}
_state.value.host != null -> {
_state.value = _state.value.copy(host = null)
} }
} }
} }
@ -134,15 +143,16 @@ class Store(
} }
private fun scan(startingIp: String) { private fun scan(startingIp: String) {
_state.value = _state.value.copy(route = Route.SCAN)
scanJob = launch { scanJob = launch {
val subnet = startingIp.substringBeforeLast(".") val subnet = startingIp.substringBeforeLast(".")
for (i in 0..255) { repeat(256) { i ->
try { try {
val ip = "$subnet.$i" val ip = "$subnet.$i"
_state.value = _state.value.copy(scanning = ip) _state.value = _state.value.copy(scanning = ip)
apiService.baseUrl = ip apiService.baseUrl = ip
apiService.getVersion() apiService.getVersion()
_state.value = _state.value.copy(scanning = null) _state.value = _state.value.copy(scanning = null, host = ip, route = Route.AUTH)
scanJob = null scanJob = null
return@launch return@launch
} catch (ignored: Exception) { } catch (ignored: Exception) {
@ -163,7 +173,8 @@ class Store(
settings[KEY_HOST] = host settings[KEY_HOST] = host
_state.value = _state.value.copy( _state.value = _state.value.copy(
host = host, host = host,
loading = false loading = false,
route = Route.AUTH,
) )
} catch (e: Exception) { } catch (e: Exception) {
_state.value = _state.value.copy(loading = false) _state.value = _state.value.copy(loading = false)
@ -181,7 +192,8 @@ class Store(
settings[KEY_API_KEY] = token settings[KEY_API_KEY] = token
_state.value = _state.value.copy( _state.value = _state.value.copy(
apiKey = token, apiKey = token,
loading = false loading = false,
route = Route.HOME,
) )
monitorChanges() monitorChanges()
} catch (e: Exception) { } catch (e: Exception) {
@ -199,11 +211,11 @@ class Store(
launch { launch {
try { try {
apiService.enable() apiService.enable()
getStatus()
loadingJob.cancel("")
} catch (e: Exception) { } catch (e: Exception) {
_state.value = _state.value.copy(loading = false) _state.value = _state.value.copy(loading = false)
_effects.emit(Effect.Error(e.message ?: "Failed to enable Pi-hole")) _effects.emit(Effect.Error(e.message ?: "Failed to enable Pi-hole"))
} finally {
loadingJob.cancel("")
} }
} }
} }
@ -216,24 +228,30 @@ class Store(
launch { launch {
try { try {
apiService.disable(duration) apiService.disable(duration)
getStatus()
loadingJob.cancel("")
} catch (e: Exception) { } catch (e: Exception) {
_state.value = _state.value.copy(loading = false) _state.value = _state.value.copy(loading = false)
_effects.emit(Effect.Error(e.message ?: "Failed to disable Pi-hole")) _effects.emit(Effect.Error(e.message ?: "Failed to disable Pi-hole"))
} finally {
loadingJob.cancel("")
} }
} }
} }
private suspend fun getStatus() { private suspend fun getStatus() {
// Don't set the state to loading here, otherwise it'll cause blinking animations val loadingJob = launch {
delay(500)
_state.value = _state.value.copy(loading = true)
}
try { try {
val summary = apiService.getSummary() val summary = apiService.getSummary()
loadingJob.cancel("")
// TODO: If status is disabled, check for how long // TODO: If status is disabled, check for how long
_state.value = _state.value.copy(status = summary.status, loading = false) _state.value = _state.value.copy(status = summary.status, loading = false)
} catch (e: Exception) { } catch (e: Exception) {
_state.value = _state.value.copy(loading = false) _state.value = _state.value.copy(loading = false)
_effects.emit(Effect.Error(e.message ?: "Failed to load status")) _effects.emit(Effect.Error(e.message ?: "Failed to load status"))
} finally {
loadingJob.cancel("")
} }
} }
@ -245,6 +263,8 @@ class Store(
} }
} }
} }
companion object
} }
expect fun String.hash(): String expect fun String.hash(): String

View file

@ -2,9 +2,17 @@ package com.wbrawner.pihelper.shared
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.darwin.* import io.ktor.client.engine.darwin.*
import io.ktor.utils.io.charsets.*
import io.ktor.utils.io.core.*
import kotlinx.cinterop.addressOf import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import platform.CoreCrypto.CC_SHA256 import platform.CoreCrypto.CC_SHA256
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
@ -12,12 +20,41 @@ fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Darwin
commonConfig() commonConfig()
}) })
actual fun String.hash(): String { fun Store.Companion.create() = Store(PiholeAPIService.create())
// TODO: This doesn't actually produce the correct values
actual fun String.hash(): String = toByteArray(Charsets.UTF_8).run {
val digest = UByteArray(CC_SHA256_DIGEST_LENGTH) val digest = UByteArray(CC_SHA256_DIGEST_LENGTH)
usePinned { inputPinned -> usePinned { inputPinned ->
digest.usePinned { digestPinned -> digest.usePinned { digestPinned ->
CC_SHA256(inputPinned.addressOf(0), this.length.convert(), digestPinned.addressOf(0)) CC_SHA256(inputPinned.addressOf(0), this.size.convert(), digestPinned.addressOf(0))
} }
} }
return digest.joinToString(separator = "") { it.toString(16) }
return digest.joinToString(separator = "", transform = { it.toString(16) })
.also {
println("hash for ${this@hash} == $it")
}
} }
fun Store.watchState(block: (State) -> Unit) = state.watch(block)
fun Store.watchEffects(block: (Effect) -> Unit) = effects.watch(block)
fun interface Closeable {
fun close()
}
class CloseableFlow<T : Any> internal constructor(private val origin: Flow<T>) : Flow<T> by origin {
fun watch(block: (T) -> Unit): Closeable {
val job = Job()
onEach {
block(it)
}.launchIn(CoroutineScope(Dispatchers.Main + job))
return Closeable { job.cancel() }
}
}
internal fun <T : Any> Flow<T>.watch(block: (T) -> Unit): Closeable =
CloseableFlow(this).watch(block)