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">
<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>

View file

@ -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>

View file

@ -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))

View file

@ -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)
}
}
}
}

View file

@ -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({}, {})
}
}

View file

@ -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()

View file

@ -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) {

View file

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

View file

@ -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"]

View file

@ -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

View file

@ -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")

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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)