Fix issues for iOS
This commit is contained in:
parent
120c155114
commit
c40e8f0274
15 changed files with 289 additions and 147 deletions
|
@ -2,18 +2,35 @@
|
|||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<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" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<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" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
|
|
|
@ -21,5 +21,10 @@
|
|||
<option name="name" value="Google" />
|
||||
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
|
||||
</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>
|
||||
</project>
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
|
@ -25,12 +26,18 @@ import com.wbrawner.pihelper.shared.Store
|
|||
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||
import java.net.Inet4Address
|
||||
|
||||
val emulatorBuildModels = listOf(
|
||||
"Android SDK built for x86",
|
||||
"sdk_gphone64_arm64"
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AddScreen(store: Store) {
|
||||
val context = LocalContext.current
|
||||
AddPiholeForm(
|
||||
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
|
||||
store.dispatch(Action.Scan("10.0.2.2"))
|
||||
return@AddPiholeForm
|
||||
|
@ -43,17 +50,21 @@ fun AddScreen(store: Store) {
|
|||
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
?: false
|
||||
}
|
||||
.forEach { network ->
|
||||
.mapNotNull { network ->
|
||||
connectivityManager.getLinkProperties(network)
|
||||
?.linkAddresses
|
||||
?.filter { !it.address.isLoopbackAddress && it.address is Inet4Address }
|
||||
?.filter {
|
||||
!it.address.isLoopbackAddress
|
||||
&& !it.address.isLinkLocalAddress
|
||||
&& it.address is Inet4Address
|
||||
}
|
||||
?.mapNotNull { it.address.hostAddress }
|
||||
?.forEach {
|
||||
store.dispatch(Action.Scan(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?: Toast.makeText(context, "Failed to scan network", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
connectToPihole = {
|
||||
store.dispatch(Action.Connect(it))
|
||||
|
|
|
@ -11,7 +11,16 @@ 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
|
||||
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.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
@ -21,6 +30,7 @@ import com.wbrawner.pihelper.shared.AuthenticationString
|
|||
import com.wbrawner.pihelper.shared.Store
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun AuthScreen(store: Store) {
|
||||
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
|
||||
|
@ -48,7 +58,9 @@ fun AuthScreen(store: Store) {
|
|||
textAlign = TextAlign.Center
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.autofill(listOf(AutofillType.Password), onFill = setPassword),
|
||||
value = password,
|
||||
onValueChange = setPassword,
|
||||
label = { Text("Pi-hole Password") },
|
||||
|
@ -59,7 +71,9 @@ fun AuthScreen(store: Store) {
|
|||
}
|
||||
OrDivider()
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.autofill(listOf(AutofillType.Password), onFill = setApiKey),
|
||||
value = apiKey,
|
||||
onValueChange = setApiKey,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +1,15 @@
|
|||
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.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
@ -17,66 +19,105 @@ import androidx.compose.ui.text.TextStyle
|
|||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
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.PihelperTheme
|
||||
|
||||
@Composable
|
||||
fun InfoScreen(store: Store) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||
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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
LoadingSpinner()
|
||||
val message = buildAnnotatedString {
|
||||
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."
|
||||
val name = text.indexOf("William")
|
||||
val github = text.indexOf("GitHub")
|
||||
append(text)
|
||||
addStringAnnotation(
|
||||
"me",
|
||||
annotation = "https://wbrawner.com",
|
||||
start = name,
|
||||
end = name + 15
|
||||
)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colors.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
start = name,
|
||||
end = name + 15
|
||||
)
|
||||
addStringAnnotation(
|
||||
"github",
|
||||
annotation = "https://github.com/wbrawner/pihelper-android",
|
||||
start = github,
|
||||
end = github + 11,
|
||||
)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colors.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
start = github,
|
||||
end = github + 11,
|
||||
)
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ClickableText(
|
||||
text = message,
|
||||
style = TextStyle.Default.copy(textAlign = TextAlign.Center)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(it)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
message.getStringAnnotations(it, it).firstOrNull()?.let { annotation ->
|
||||
uriHandler.openUri(annotation.item)
|
||||
LoadingSpinner()
|
||||
val message = buildAnnotatedString {
|
||||
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."
|
||||
val name = text.indexOf("William")
|
||||
val github = text.indexOf("GitHub")
|
||||
append(text)
|
||||
addStringAnnotation(
|
||||
"me",
|
||||
annotation = "https://wbrawner.com",
|
||||
start = name,
|
||||
end = name + 15
|
||||
)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colors.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
start = name,
|
||||
end = name + 15
|
||||
)
|
||||
addStringAnnotation(
|
||||
"github",
|
||||
annotation = "https://github.com/wbrawner/pihelper-android",
|
||||
start = github,
|
||||
end = github + 11,
|
||||
)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colors.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
start = github,
|
||||
end = github + 11,
|
||||
)
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ClickableText(
|
||||
text = message,
|
||||
style = TextStyle.Default.copy(
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
) {
|
||||
message.getStringAnnotations(it, it).firstOrNull()?.let { annotation ->
|
||||
uriHandler.openUri(annotation.item)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onForgetPiholeClicked) {
|
||||
Text(text = "Forget Pi-hole")
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { store.dispatch(Action.Forget) }) {
|
||||
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({}, {})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ import androidx.navigation.compose.composable
|
|||
import androidx.navigation.compose.rememberNavController
|
||||
import com.wbrawner.pihelper.shared.Action
|
||||
import com.wbrawner.pihelper.shared.Effect
|
||||
import com.wbrawner.pihelper.shared.Route
|
||||
import com.wbrawner.pihelper.shared.Store
|
||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
@ -66,29 +67,9 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
val state by store.state.collectAsState()
|
||||
val navController = rememberNavController()
|
||||
val startDestination = when {
|
||||
state.host == null -> {
|
||||
Screens.ADD
|
||||
}
|
||||
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)
|
||||
}
|
||||
LaunchedEffect(state.route) {
|
||||
println("navigating to ${state.route.name}")
|
||||
navController.navigate(state.route.name)
|
||||
}
|
||||
val effect by store.effects.collectAsState(initial = Effect.Empty)
|
||||
val context = LocalContext.current
|
||||
|
@ -103,20 +84,20 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
PihelperTheme {
|
||||
NavHost(navController, startDestination = startDestination.route) {
|
||||
composable(Screens.ADD.route) {
|
||||
NavHost(navController, startDestination = state.initialRoute.name) {
|
||||
composable(Route.CONNECT.name) {
|
||||
AddScreen(store)
|
||||
}
|
||||
composable(Screens.SCAN.route) {
|
||||
composable(Route.SCAN.name) {
|
||||
ScanScreen(store)
|
||||
}
|
||||
composable(Screens.AUTH.route) {
|
||||
composable(Route.AUTH.name) {
|
||||
AuthScreen(store)
|
||||
}
|
||||
composable(Screens.MAIN.route) {
|
||||
composable(Route.HOME.name) {
|
||||
MainScreen(store)
|
||||
}
|
||||
composable(Screens.INFO.route) {
|
||||
composable(Route.ABOUT.name) {
|
||||
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
|
||||
fun LoadingSpinner(animate: Boolean = false) {
|
||||
val animation = rememberInfiniteTransition()
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package com.wbrawner.pihelper
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
@ -59,7 +59,7 @@ fun MainScreen(store: Store) {
|
|||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LoadingSpinner(state.value.loading)
|
||||
AnimatedVisibility(visible = !state.value.loading) {
|
||||
AnimatedContent(targetState = state.value.status, contentAlignment = Alignment.Center) {
|
||||
state.value.status?.let {
|
||||
StatusLabel(it)
|
||||
if (it == Status.ENABLED) {
|
||||
|
|
|
@ -8,3 +8,10 @@ buildscript {
|
|||
classpath(libs.bundles.plugins)
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ compose = "1.1.1"
|
|||
coroutines = "1.4.3"
|
||||
espresso = "3.3.0"
|
||||
hilt-android = "2.38.1"
|
||||
kotlin = "1.6.10"
|
||||
kotlin = "1.7.20"
|
||||
kotlinx-serialization = "1.3.2"
|
||||
kotlinx-coroutines = "1.6.0-native-mt"
|
||||
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" }
|
||||
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" }
|
||||
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" }
|
||||
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]
|
||||
compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"]
|
||||
coroutines = ["coroutines-core", "coroutines-android"]
|
||||
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt"]
|
||||
plugins = ["android-gradle", "kotlin-gradle", "dagger-hilt", "kotlin-serialization"]
|
||||
|
||||
|
||||
|
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
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
|
||||
|
|
|
@ -1,11 +1,4 @@
|
|||
enableFeaturePreview("VERSION_CATALOGS")
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "Pi-helper"
|
||||
include(":app")
|
||||
include(":shared")
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("com.android.library")
|
||||
kotlin("plugin.serialization") version "1.6.10"
|
||||
kotlin("plugin.serialization")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android()
|
||||
val iosX64 = iosX64()
|
||||
val iosArm64 = iosArm64()
|
||||
val iosSimulatorArm64 = iosSimulatorArm64()
|
||||
listOf(iosX64, iosArm64, iosSimulatorArm64).forEach {
|
||||
listOf(iosArm64(), iosSimulatorArm64()).forEach {
|
||||
it.binaries.framework {
|
||||
baseName = "Pihelper"
|
||||
}
|
||||
|
@ -34,12 +31,10 @@ kotlin {
|
|||
}
|
||||
}
|
||||
|
||||
val iosX64Main by getting
|
||||
val iosArm64Main by getting
|
||||
val iosSimulatorArm64Main by getting
|
||||
val iosMain by creating {
|
||||
dependsOn(commonMain)
|
||||
iosX64Main.dependsOn(this)
|
||||
iosArm64Main.dependsOn(this)
|
||||
iosSimulatorArm64Main.dependsOn(this)
|
||||
dependencies {
|
||||
|
|
|
@ -33,15 +33,19 @@ fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
|
|||
})
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 500
|
||||
connectTimeoutMillis = 500
|
||||
socketTimeoutMillis = 500
|
||||
requestTimeoutMillis = 1000
|
||||
connectTimeoutMillis = 1000
|
||||
socketTimeoutMillis = 1000
|
||||
}
|
||||
}
|
||||
|
||||
class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
||||
override var baseUrl: String? = null
|
||||
override var apiKey: String? = null
|
||||
get() {
|
||||
println("apiKey: $field")
|
||||
return field
|
||||
}
|
||||
|
||||
override suspend fun getSummary(): Summary = httpClient.get {
|
||||
url {
|
||||
|
|
|
@ -9,13 +9,22 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class Route(val path: String) {
|
||||
CONNECT("connect"),
|
||||
SCAN("scan"),
|
||||
AUTH("auth"),
|
||||
HOME("home"),
|
||||
ABOUT("about"),
|
||||
}
|
||||
|
||||
data class State(
|
||||
val apiKey: String? = null,
|
||||
val host: String? = null,
|
||||
val status: Status? = null,
|
||||
val scanning: String? = null,
|
||||
val loading: Boolean = false,
|
||||
val showAbout: Boolean = false,
|
||||
val route: Route = Route.CONNECT,
|
||||
val initialRoute: Route = Route.CONNECT
|
||||
)
|
||||
|
||||
sealed interface AuthenticationString {
|
||||
|
@ -70,7 +79,9 @@ class Store(
|
|||
apiService.apiKey = apiKey
|
||||
_state.value = initialState.copy(
|
||||
host = host,
|
||||
apiKey = apiKey
|
||||
apiKey = apiKey,
|
||||
route = Route.HOME,
|
||||
initialRoute = Route.HOME
|
||||
)
|
||||
monitorChanges()
|
||||
} else {
|
||||
|
@ -81,6 +92,7 @@ class Store(
|
|||
}
|
||||
|
||||
fun dispatch(action: Action) {
|
||||
println(action)
|
||||
when (action) {
|
||||
is Action.Authenticate -> {
|
||||
when (action.authString) {
|
||||
|
@ -96,31 +108,28 @@ class Store(
|
|||
Action.Enable -> enable()
|
||||
Action.Forget -> forget()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun back() {
|
||||
when {
|
||||
_state.value.showAbout -> {
|
||||
_state.value = _state.value.copy(showAbout = false)
|
||||
when (_state.value.route) {
|
||||
Route.ABOUT -> {
|
||||
_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 {
|
||||
_effects.emit(Effect.Exit)
|
||||
}
|
||||
}
|
||||
_state.value.scanning != null -> {
|
||||
_state.value = _state.value.copy(scanning = null)
|
||||
Route.SCAN -> {
|
||||
scanJob?.cancel("")
|
||||
scanJob = null
|
||||
}
|
||||
_state.value.apiKey != null -> {
|
||||
_state.value = _state.value.copy(apiKey = null)
|
||||
}
|
||||
_state.value.host != null -> {
|
||||
_state.value = _state.value.copy(host = null)
|
||||
_state.value = _state.value.copy(scanning = null, route = Route.CONNECT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -134,15 +143,16 @@ class Store(
|
|||
}
|
||||
|
||||
private fun scan(startingIp: String) {
|
||||
_state.value = _state.value.copy(route = Route.SCAN)
|
||||
scanJob = launch {
|
||||
val subnet = startingIp.substringBeforeLast(".")
|
||||
for (i in 0..255) {
|
||||
repeat(256) { i ->
|
||||
try {
|
||||
val ip = "$subnet.$i"
|
||||
_state.value = _state.value.copy(scanning = ip)
|
||||
apiService.baseUrl = ip
|
||||
apiService.getVersion()
|
||||
_state.value = _state.value.copy(scanning = null)
|
||||
_state.value = _state.value.copy(scanning = null, host = ip, route = Route.AUTH)
|
||||
scanJob = null
|
||||
return@launch
|
||||
} catch (ignored: Exception) {
|
||||
|
@ -163,7 +173,8 @@ class Store(
|
|||
settings[KEY_HOST] = host
|
||||
_state.value = _state.value.copy(
|
||||
host = host,
|
||||
loading = false
|
||||
loading = false,
|
||||
route = Route.AUTH,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(loading = false)
|
||||
|
@ -181,7 +192,8 @@ class Store(
|
|||
settings[KEY_API_KEY] = token
|
||||
_state.value = _state.value.copy(
|
||||
apiKey = token,
|
||||
loading = false
|
||||
loading = false,
|
||||
route = Route.HOME,
|
||||
)
|
||||
monitorChanges()
|
||||
} catch (e: Exception) {
|
||||
|
@ -199,11 +211,11 @@ class Store(
|
|||
launch {
|
||||
try {
|
||||
apiService.enable()
|
||||
getStatus()
|
||||
loadingJob.cancel("")
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(loading = false)
|
||||
_effects.emit(Effect.Error(e.message ?: "Failed to enable Pi-hole"))
|
||||
} finally {
|
||||
loadingJob.cancel("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -216,24 +228,30 @@ class Store(
|
|||
launch {
|
||||
try {
|
||||
apiService.disable(duration)
|
||||
getStatus()
|
||||
loadingJob.cancel("")
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(loading = false)
|
||||
_effects.emit(Effect.Error(e.message ?: "Failed to disable Pi-hole"))
|
||||
} finally {
|
||||
loadingJob.cancel("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
val summary = apiService.getSummary()
|
||||
loadingJob.cancel("")
|
||||
// TODO: If status is disabled, check for how long
|
||||
_state.value = _state.value.copy(status = summary.status, loading = false)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(loading = false)
|
||||
_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
|
|
@ -2,9 +2,17 @@ package com.wbrawner.pihelper.shared
|
|||
|
||||
import io.ktor.client.*
|
||||
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.convert
|
||||
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_DIGEST_LENGTH
|
||||
|
||||
|
@ -12,12 +20,41 @@ fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Darwin
|
|||
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)
|
||||
usePinned { inputPinned ->
|
||||
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)
|
Loading…
Reference in a new issue