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">
|
<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>
|
||||||
|
|
|
@ -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>
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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({}, {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -8,3 +8,10 @@ buildscript {
|
||||||
classpath(libs.bundles.plugins)
|
classpath(libs.bundles.plugins)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
||||||
|
|
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
|
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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
Loading…
Reference in a new issue