Migrate to redux-style store in shared module
This commit is contained in:
parent
fc06a2f91d
commit
a44e48d1e1
19 changed files with 452 additions and 409 deletions
|
@ -28,6 +28,10 @@
|
|||
<entry key="../../../../layout/compose-model-1646331413004.xml" value="0.4506079027355623" />
|
||||
<entry key="../../../../layout/compose-model-1646332602415.xml" value="0.13775083612040134" />
|
||||
<entry key="../../../../layout/compose-model-1646358533232.xml" value="0.30153061224489797" />
|
||||
<entry key="../../../../layout/compose-model-1647060386627.xml" value="0.1" />
|
||||
<entry key="../../../../layout/compose-model-1647060537103.xml" value="0.12374581939799331" />
|
||||
<entry key="../../../../layout/compose-model-1647101512079.xml" value="0.12332775919732442" />
|
||||
<entry key="../../../../layout/compose-model-1647102165810.xml" value="0.1" />
|
||||
<entry key="app/src/main/res/drawable-v26/ic_shortcut_enable.xml" value="0.27447916666666666" />
|
||||
<entry key="app/src/main/res/drawable-v26/ic_shortcut_pause.xml" value="0.27447916666666666" />
|
||||
<entry key="app/src/main/res/drawable/background_splash.xml" value="0.409375" />
|
||||
|
|
|
@ -1,142 +0,0 @@
|
|||
package com.wbrawner.pihelper
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.wbrawner.pihelper.shared.PiholeAPIService
|
||||
import com.wbrawner.pihelper.shared.VersionResponse
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import javax.inject.Inject
|
||||
|
||||
const val KEY_BASE_URL = "baseUrl"
|
||||
const val KEY_API_KEY = "apiKey"
|
||||
const val IP_MIN = 0
|
||||
const val IP_MAX = 255
|
||||
|
||||
@HiltViewModel
|
||||
class AddPiHelperViewModel @Inject constructor(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val apiService: PiholeAPIService
|
||||
) : ViewModel() {
|
||||
|
||||
@Volatile
|
||||
var baseUrl: String? = sharedPreferences.getString(KEY_BASE_URL, null)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putString(KEY_BASE_URL, value)
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var apiKey: String? = sharedPreferences.getString(KEY_API_KEY, null)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putString(KEY_API_KEY, value)
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
init {
|
||||
apiService.baseUrl = this.baseUrl
|
||||
apiService.apiKey = this.apiKey
|
||||
}
|
||||
|
||||
val loadingMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage = MutableStateFlow<String?>(null)
|
||||
|
||||
suspend fun beginScanning(deviceIpAddress: String, onSuccess: () -> Unit, onFailure: () -> Unit) {
|
||||
val addressParts = deviceIpAddress.split(".").toMutableList()
|
||||
var chunks = 1
|
||||
// If the Pi-hole is correctly set up, then there should be a special host for it as
|
||||
// "pi.hole"
|
||||
val ipAddresses = mutableListOf("pi.hole")
|
||||
while (chunks <= IP_MAX) {
|
||||
val chunkSize = (IP_MAX - IP_MIN + 1) / chunks
|
||||
if (chunkSize == 1) {
|
||||
return
|
||||
}
|
||||
for (chunk in 0 until chunks) {
|
||||
val chunkStart = IP_MIN + (chunk * chunkSize)
|
||||
val chunkEnd = IP_MIN + ((chunk + 1) * chunkSize)
|
||||
addressParts[3] = (((chunkEnd - chunkStart) / 2) + chunkStart).toString()
|
||||
ipAddresses.add(addressParts.joinToString("."))
|
||||
}
|
||||
chunks *= 2
|
||||
}
|
||||
if (scan(ipAddresses)) {
|
||||
onSuccess()
|
||||
} else {
|
||||
onFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun scan(ipAddresses: MutableList<String>): Boolean {
|
||||
if (ipAddresses.isEmpty()) {
|
||||
loadingMessage.value = null
|
||||
return false
|
||||
}
|
||||
|
||||
val ipAddress = ipAddresses.removeAt(0)
|
||||
loadingMessage.value = "Scanning $ipAddress..."
|
||||
return if (!connectToIpAddress(ipAddress)) {
|
||||
scan(ipAddresses)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectToIpAddress(ipAddress: String): Boolean {
|
||||
val version: VersionResponse? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
apiService.baseUrl = ipAddress
|
||||
apiService.getVersion()
|
||||
} catch (ignored: ConnectException) {
|
||||
null
|
||||
} catch (ignored: SocketTimeoutException) {
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e("Pi-helper", "Failed to load Pi-Hole version at $ipAddress", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
if (version != null) {
|
||||
baseUrl = ipAddress
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun authenticateWithPassword(password: String): Boolean {
|
||||
// The Pi-hole API key is just the web password hashed twice with SHA-256
|
||||
return authenticateWithApiKey(password.hash().hash())
|
||||
}
|
||||
|
||||
suspend fun authenticateWithApiKey(apiKey: String): Boolean {
|
||||
// This uses the topItems endpoint to test that the API key is working since it requires
|
||||
// authentication and is fairly simple to determine whether or not the request was
|
||||
// successful
|
||||
errorMessage.value = null
|
||||
apiService.apiKey = apiKey
|
||||
return try {
|
||||
apiService.getTopItems()
|
||||
this.apiKey = apiKey
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("Pi-helper", "Unable to authenticate with API key", e)
|
||||
errorMessage.value = "Authentication failed"
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun forgetPihole() {
|
||||
baseUrl = null
|
||||
apiKey = null
|
||||
}
|
||||
}
|
|
@ -1,38 +1,64 @@
|
|||
package com.wbrawner.pihelper
|
||||
|
||||
import android.util.Log
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.pihelper.shared.Action
|
||||
import com.wbrawner.pihelper.shared.Store
|
||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.Inet4Address
|
||||
|
||||
@Composable
|
||||
fun AddScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val loadingMessage by addPiHelperViewModel.loadingMessage.collectAsState()
|
||||
fun AddScreen(store: Store) {
|
||||
val context = LocalContext.current
|
||||
AddPiholeForm(
|
||||
scanNetwork = { navController.navigate(Screens.SCAN.route) },
|
||||
connectToPihole = {
|
||||
coroutineScope.launch {
|
||||
if (addPiHelperViewModel.connectToIpAddress(it)) {
|
||||
Log.d("AddScreen", "Connected, going to auth")
|
||||
navController.navigate(Screens.AUTH.route)
|
||||
scanNetwork = {
|
||||
if (BuildConfig.DEBUG && Build.MODEL == "Android SDK built for x86") {
|
||||
// For emulators, just begin scanning the host machine directly
|
||||
store.dispatch(Action.Scan("10.0.2.2"))
|
||||
return@AddPiholeForm
|
||||
}
|
||||
(context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)
|
||||
?.let { connectivityManager ->
|
||||
connectivityManager.allNetworks
|
||||
.filter {
|
||||
connectivityManager.getNetworkCapabilities(it)
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
?: false
|
||||
}
|
||||
.forEach { network ->
|
||||
connectivityManager.getLinkProperties(network)
|
||||
?.linkAddresses
|
||||
?.filter { !it.address.isLoopbackAddress && it.address is Inet4Address }
|
||||
?.mapNotNull { it.address.hostAddress }
|
||||
?.forEach {
|
||||
store.dispatch(Action.Scan(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
loadingMessage = loadingMessage
|
||||
connectToPihole = {
|
||||
store.dispatch(Action.Connect(it))
|
||||
},
|
||||
store.state.value.loading
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -40,7 +66,7 @@ fun AddScreen(navController: NavController, addPiHelperViewModel: AddPiHelperVie
|
|||
fun AddPiholeForm(
|
||||
scanNetwork: () -> Unit,
|
||||
connectToPihole: (String) -> Unit,
|
||||
loadingMessage: String? = null
|
||||
loading: Boolean = false
|
||||
) {
|
||||
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
|
||||
Column(
|
||||
|
@ -50,7 +76,7 @@ fun AddPiholeForm(
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
LoadingSpinner(loadingMessage != null)
|
||||
LoadingSpinner(loading)
|
||||
Text(
|
||||
text = "If you're not sure what the IP address for your Pi-hole is, Pi-helper can " +
|
||||
"attempt to find it for you by scanning your network.",
|
||||
|
|
|
@ -16,11 +16,13 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.pihelper.shared.Action
|
||||
import com.wbrawner.pihelper.shared.AuthenticationString
|
||||
import com.wbrawner.pihelper.shared.Store
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) {
|
||||
fun AuthScreen(store: Store) {
|
||||
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
|
||||
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
@ -53,11 +55,7 @@ fun AuthScreen(navController: NavController, addPiHelperViewModel: AddPiHelperVi
|
|||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
PrimaryButton(text = "Authenticate with Password") {
|
||||
coroutineScope.launch {
|
||||
if (addPiHelperViewModel.authenticateWithPassword(password)) {
|
||||
navController.navigate(Screens.MAIN.route)
|
||||
}
|
||||
}
|
||||
store.dispatch(Action.Authenticate(AuthenticationString.Password(password)))
|
||||
}
|
||||
OrDivider()
|
||||
OutlinedTextField(
|
||||
|
@ -69,9 +67,7 @@ fun AuthScreen(navController: NavController, addPiHelperViewModel: AddPiHelperVi
|
|||
)
|
||||
PrimaryButton(text = "Authenticate with API Key") {
|
||||
coroutineScope.launch {
|
||||
if (addPiHelperViewModel.authenticateWithApiKey(password)) {
|
||||
navController.navigate(Screens.MAIN.route)
|
||||
}
|
||||
store.dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
package com.wbrawner.pihelper
|
||||
|
||||
import android.view.View
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigInteger
|
||||
import java.security.MessageDigest
|
||||
|
||||
fun String.hash(): String = BigInteger(
|
||||
1,
|
||||
MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
|
||||
).toString(16).padStart(64, '0')
|
||||
|
||||
fun CoroutineScope.cancel() {
|
||||
coroutineContext[Job]?.cancel()
|
||||
}
|
||||
|
||||
fun View.setSuspendingOnClickListener(
|
||||
coroutineScope: CoroutineScope,
|
||||
clickListener: suspend (v: View) -> Unit
|
||||
) = setOnClickListener { v ->
|
||||
coroutineScope.launch { clickListener(v) }
|
||||
}
|
|
@ -17,14 +17,12 @@ 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.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavOptions
|
||||
import com.wbrawner.pihelper.shared.Action
|
||||
import com.wbrawner.pihelper.shared.Store
|
||||
|
||||
@Composable
|
||||
fun InfoScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) {
|
||||
fun InfoScreen(store: Store) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
|
@ -77,11 +75,7 @@ fun InfoScreen(navController: NavController, addPiHelperViewModel: AddPiHelperVi
|
|||
uriHandler.openUri(annotation.item)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {
|
||||
addPiHelperViewModel.forgetPihole()
|
||||
navController.navigate(Screens.ADD.route)
|
||||
navController.backQueue.clear()
|
||||
}) {
|
||||
TextButton(onClick = { store.dispatch(Action.Forget) }) {
|
||||
Text(text = "Forget Pi-hole")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.os.Build
|
|||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowInsetsController
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
|
@ -13,23 +14,31 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
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.Store
|
||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@Inject
|
||||
lateinit var store: Store
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -37,7 +46,8 @@ class MainActivity : AppCompatActivity() {
|
|||
val isDarkTheme = isSystemInDarkTheme()
|
||||
LaunchedEffect(key1 = isDarkTheme) {
|
||||
if (isDarkTheme) return@LaunchedEffect
|
||||
window.navigationBarColor = ContextCompat.getColor(this@MainActivity, R.color.colorSurface)
|
||||
window.navigationBarColor =
|
||||
ContextCompat.getColor(this@MainActivity, R.color.colorSurface)
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||
window.insetsController?.setSystemBarsAppearance(
|
||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
|
||||
|
@ -49,46 +59,69 @@ class MainActivity : AppCompatActivity() {
|
|||
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
|
||||
}
|
||||
}
|
||||
val state by store.state.collectAsState()
|
||||
val navController = rememberNavController()
|
||||
val addPiHoleViewModel: AddPiHelperViewModel = viewModel()
|
||||
val startDestination = when {
|
||||
addPiHoleViewModel.baseUrl == null -> {
|
||||
state.host == null -> {
|
||||
Screens.ADD
|
||||
}
|
||||
addPiHoleViewModel.apiKey == null -> {
|
||||
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 context = LocalContext.current
|
||||
LaunchedEffect(effect) {
|
||||
when (effect) {
|
||||
is Effect.Error -> Toast.makeText(
|
||||
context,
|
||||
(effect as Effect.Error).message,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
is Effect.Exit -> finish()
|
||||
}
|
||||
}
|
||||
PihelperTheme {
|
||||
NavHost(navController, startDestination = startDestination.route) {
|
||||
composable(Screens.ADD.route) {
|
||||
AddScreen(navController, addPiHoleViewModel)
|
||||
AddScreen(store)
|
||||
}
|
||||
composable(Screens.SCAN.route) {
|
||||
ScanScreen(navController, addPiHoleViewModel)
|
||||
ScanScreen(store)
|
||||
}
|
||||
composable(Screens.AUTH.route) {
|
||||
AuthScreen(
|
||||
navController = navController,
|
||||
addPiHelperViewModel = addPiHoleViewModel
|
||||
)
|
||||
AuthScreen(store)
|
||||
}
|
||||
composable(Screens.MAIN.route) {
|
||||
MainScreen(navController = navController)
|
||||
MainScreen(store)
|
||||
}
|
||||
composable(Screens.INFO.route) {
|
||||
InfoScreen(
|
||||
navController = navController,
|
||||
addPiHelperViewModel = addPiHoleViewModel
|
||||
)
|
||||
InfoScreen(store)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
store.dispatch(Action.Back)
|
||||
}
|
||||
}
|
||||
|
||||
enum class Screens(val route: String) {
|
||||
|
@ -124,3 +157,9 @@ fun LoadingSpinner(animate: Boolean = false) {
|
|||
fun LoadingSpinner_Preview() {
|
||||
LoadingSpinner()
|
||||
}
|
||||
|
||||
fun NavController.navigateIfNotAlreadyThere(route: String) {
|
||||
if (currentDestination?.route != route) {
|
||||
navigate(route)
|
||||
}
|
||||
}
|
|
@ -9,17 +9,19 @@ import androidx.compose.foundation.verticalScroll
|
|||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.wbrawner.pihelper.shared.Action
|
||||
import com.wbrawner.pihelper.shared.Status
|
||||
import com.wbrawner.pihelper.shared.Store
|
||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||
import java.util.*
|
||||
import kotlin.math.pow
|
||||
|
@ -27,11 +29,8 @@ import kotlin.math.roundToLong
|
|||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
fun MainScreen(navController: NavController, viewModel: PiHelperViewModel = hiltViewModel()) {
|
||||
LaunchedEffect(key1 = viewModel) {
|
||||
viewModel.monitorSummary()
|
||||
}
|
||||
val status by viewModel.status.collectAsState()
|
||||
fun MainScreen(store: Store) {
|
||||
val state = store.state.collectAsState()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
@ -40,7 +39,7 @@ fun MainScreen(navController: NavController, viewModel: PiHelperViewModel = hilt
|
|||
contentColor = MaterialTheme.colors.onBackground,
|
||||
elevation = 0.dp,
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate(Screens.INFO.route) }) {
|
||||
IconButton(onClick = { store.dispatch(Action.About) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
|
@ -59,26 +58,18 @@ fun MainScreen(navController: NavController, viewModel: PiHelperViewModel = hilt
|
|||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LoadingSpinner(status == Status.LOADING)
|
||||
AnimatedVisibility(visible = status != Status.LOADING) {
|
||||
StatusLabel(status)
|
||||
if (status == Status.ENABLED) {
|
||||
DisableControls(viewModel)
|
||||
LoadingSpinner(state.value.loading)
|
||||
AnimatedVisibility(visible = !state.value.loading) {
|
||||
state.value.status?.let {
|
||||
StatusLabel(it)
|
||||
if (it == Status.ENABLED) {
|
||||
DisableControls { duration -> store.dispatch(Action.Disable(duration)) }
|
||||
} else {
|
||||
EnableControls(viewModel::enablePiHole)
|
||||
EnableControls { store.dispatch(Action.Enable) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
@Composable
|
||||
@Preview
|
||||
fun MainScreen_Preview() {
|
||||
val navController = rememberNavController()
|
||||
PihelperTheme(false) {
|
||||
MainScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -121,7 +112,7 @@ fun EnableControls(onClick: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun DisableControls(viewModel: PiHelperViewModel = hiltViewModel()) {
|
||||
fun DisableControls(disable: (duration: Long?) -> Unit) {
|
||||
val (dialogVisible, setDialogVisible) = remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
@ -130,13 +121,13 @@ fun DisableControls(viewModel: PiHelperViewModel = hiltViewModel()) {
|
|||
.padding(start = 16.dp, top = 48.dp, end = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
|
||||
) {
|
||||
PrimaryButton("Disable for 10 seconds") { viewModel.disablePiHole(10) }
|
||||
PrimaryButton("Disable for 30 seconds") { viewModel.disablePiHole(30) }
|
||||
PrimaryButton("Disable for 5 minutes") { viewModel.disablePiHole(300) }
|
||||
PrimaryButton("Disable for 10 seconds") { disable(10) }
|
||||
PrimaryButton("Disable for 30 seconds") { disable(30) }
|
||||
PrimaryButton("Disable for 5 minutes") { disable(300) }
|
||||
PrimaryButton("Disable for custom time") { setDialogVisible(true) }
|
||||
PrimaryButton("Disable permanently") { viewModel.disablePiHole() }
|
||||
PrimaryButton("Disable permanently") { disable(null) }
|
||||
CustomTimeDialog(dialogVisible, setDialogVisible) {
|
||||
viewModel.disablePiHole(it)
|
||||
disable(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -325,5 +316,5 @@ fun EnableControls_DarkPreview() {
|
|||
@Composable
|
||||
@Preview
|
||||
fun DisableControls_Preview() {
|
||||
DisableControls()
|
||||
DisableControls({})
|
||||
}
|
||||
|
|
|
@ -1,42 +1,24 @@
|
|||
package com.wbrawner.pihelper
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import com.wbrawner.pihelper.shared.PiholeAPIService
|
||||
import com.wbrawner.pihelper.shared.Store
|
||||
import com.wbrawner.pihelper.shared.create
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
const val ENCRYPTED_SHARED_PREFS_FILE_NAME = "pihelper.prefs"
|
||||
const val NAME_BASE_URL = "baseUrl"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PiHelperModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
|
||||
EncryptedSharedPreferences.create(
|
||||
ENCRYPTED_SHARED_PREFS_FILE_NAME,
|
||||
"pihelper",
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named(NAME_BASE_URL)
|
||||
fun providesBaseUrl(sharedPreferences: SharedPreferences) = sharedPreferences
|
||||
.getString(KEY_BASE_URL, "")
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesPiholeAPIService(): PiholeAPIService = PiholeAPIService.create()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesStore(
|
||||
apiService: PiholeAPIService,
|
||||
): Store = Store(apiService)
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
package com.wbrawner.pihelper
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.wbrawner.pihelper.shared.PiholeAPIService
|
||||
import com.wbrawner.pihelper.shared.Status
|
||||
import com.wbrawner.pihelper.shared.StatusProvider
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@HiltViewModel
|
||||
class PiHelperViewModel @Inject constructor(
|
||||
private val apiService: PiholeAPIService
|
||||
) : ViewModel() {
|
||||
private val _status = MutableStateFlow(Status.LOADING)
|
||||
val status = _status.asStateFlow()
|
||||
private var action: (suspend () -> StatusProvider)? = null
|
||||
get() = field ?: defaultAction
|
||||
private var defaultAction = suspend {
|
||||
apiService.getSummary()
|
||||
}
|
||||
|
||||
suspend fun monitorSummary() {
|
||||
while (coroutineContext.isActive) {
|
||||
try {
|
||||
_status.value = action!!.invoke().status
|
||||
action = null
|
||||
} catch (ignored: Exception) {
|
||||
_status.value = Status.UNKNOWN
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
fun enablePiHole() {
|
||||
_status.value = Status.LOADING
|
||||
action = {
|
||||
apiService.enable()
|
||||
}
|
||||
}
|
||||
|
||||
fun disablePiHole(duration: Long? = null) {
|
||||
_status.value = Status.LOADING
|
||||
action = {
|
||||
apiService.disable(duration)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,77 +1,28 @@
|
|||
package com.wbrawner.pihelper
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
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.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.pihelper.shared.Store
|
||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.Inet4Address
|
||||
|
||||
@Composable
|
||||
fun ScanScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val loadingMessage by addPiHelperViewModel.loadingMessage.collectAsState()
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(key1 = addPiHelperViewModel) {
|
||||
val onSuccess: () -> Unit = {
|
||||
navController.navigate(Screens.AUTH.route)
|
||||
}
|
||||
val onFailure: () -> Unit = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
if (BuildConfig.DEBUG && Build.MODEL == "Android SDK built for x86") {
|
||||
// For emulators, just begin scanning the host machine directly
|
||||
coroutineScope.launch {
|
||||
addPiHelperViewModel.beginScanning("10.0.2.2", onSuccess, onFailure)
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
(context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.let { connectivityManager ->
|
||||
connectivityManager.allNetworks
|
||||
.filter {
|
||||
connectivityManager.getNetworkCapabilities(it)
|
||||
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
?: false
|
||||
}
|
||||
.forEach { network ->
|
||||
connectivityManager.getLinkProperties(network)
|
||||
?.linkAddresses
|
||||
?.filter { !it.address.isLoopbackAddress && it.address is Inet4Address }
|
||||
?.forEach { address ->
|
||||
addPiHelperViewModel.beginScanning(
|
||||
address.address.hostAddress,
|
||||
onSuccess,
|
||||
onFailure
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ScanningStatus(loadingMessage)
|
||||
fun ScanScreen(store: Store) {
|
||||
ScanningStatus(store.state.value.scanning?.let { "Scanning $it..." })
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScanningStatus(
|
||||
loadingMessage: String? = null
|
||||
) {
|
||||
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
|
|
|
@ -16,6 +16,7 @@ moshi = "1.9.2"
|
|||
minSdk = "23"
|
||||
navigation = "2.4.1"
|
||||
okhttp = "4.2.2"
|
||||
settings = "0.8.1"
|
||||
versionCode = "1"
|
||||
versionName = "1.0"
|
||||
|
||||
|
@ -50,6 +51,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
|
|||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||
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" }
|
||||
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
|
||||
moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
|
||||
|
|
|
@ -6,11 +6,10 @@ plugins {
|
|||
|
||||
kotlin {
|
||||
android()
|
||||
listOf(
|
||||
iosX64(),
|
||||
iosArm64(),
|
||||
iosSimulatorArm64()
|
||||
).forEach {
|
||||
val iosX64 = iosX64()
|
||||
val iosArm64 = iosArm64()
|
||||
val iosSimulatorArm64 = iosSimulatorArm64()
|
||||
listOf(iosX64, iosArm64, iosSimulatorArm64).forEach {
|
||||
it.binaries.framework {
|
||||
baseName = "Pihelper"
|
||||
}
|
||||
|
@ -25,6 +24,7 @@ kotlin {
|
|||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
api(libs.multiplatform.settings)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
package com.wbrawner.pihelper.shared
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.android.*
|
||||
import java.math.BigInteger
|
||||
import java.security.MessageDigest
|
||||
|
||||
fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Android) {
|
||||
commonConfig()
|
||||
})
|
||||
|
||||
actual fun String.hash(): String = BigInteger(
|
||||
1,
|
||||
MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
|
||||
).toString(16).padStart(64, '0')
|
|
@ -1,8 +0,0 @@
|
|||
package com.wbrawner.pihelper.shared
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.android.*
|
||||
|
||||
fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Android) {
|
||||
commonConfig()
|
||||
})
|
|
@ -32,6 +32,11 @@ fun <T: HttpClientEngineConfig> HttpClientConfig<T>.commonConfig() {
|
|||
isLenient = true
|
||||
})
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 500
|
||||
connectTimeoutMillis = 500
|
||||
socketTimeoutMillis = 500
|
||||
}
|
||||
}
|
||||
|
||||
class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
||||
|
|
|
@ -0,0 +1,250 @@
|
|||
package com.wbrawner.pihelper.shared
|
||||
|
||||
import com.russhwolf.settings.Settings
|
||||
import com.russhwolf.settings.get
|
||||
import com.russhwolf.settings.set
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
sealed interface AuthenticationString {
|
||||
val value: String
|
||||
|
||||
data class Password(override val value: String) : AuthenticationString
|
||||
data class Token(override val value: String) : AuthenticationString
|
||||
}
|
||||
|
||||
sealed interface Action {
|
||||
data class Connect(val host: String) : Action
|
||||
data class Scan(val deviceIp: String) : Action
|
||||
data class Authenticate(val authString: AuthenticationString) : Action
|
||||
object Enable : Action
|
||||
data class Disable(val duration: Long? = null) : Action
|
||||
object Forget : Action
|
||||
object About : Action
|
||||
object Back : Action
|
||||
}
|
||||
|
||||
sealed interface Effect {
|
||||
object Exit : Effect
|
||||
data class Error(val message: String) : Effect
|
||||
object Empty
|
||||
}
|
||||
|
||||
const val KEY_HOST = "baseUrl"
|
||||
const val KEY_API_KEY = "apiKey"
|
||||
|
||||
class Store(
|
||||
private val apiService: PiholeAPIService,
|
||||
private val settings: Settings = Settings(),
|
||||
initialState: State = State()
|
||||
) : CoroutineScope by CoroutineScope(Dispatchers.Main) {
|
||||
private val _state = MutableStateFlow(initialState)
|
||||
val state: StateFlow<State> = _state
|
||||
private val _effects = MutableSharedFlow<Effect>()
|
||||
val effects: Flow<Effect> = _effects
|
||||
private var monitorJob: Job? = null
|
||||
private var scanJob: Job? = null
|
||||
|
||||
init {
|
||||
launch {
|
||||
_state.collect {
|
||||
println(it)
|
||||
}
|
||||
}
|
||||
val host: String? = settings[KEY_HOST]
|
||||
val apiKey: String? = settings[KEY_API_KEY]
|
||||
if (!host.isNullOrBlank() && !apiKey.isNullOrBlank()) {
|
||||
apiService.baseUrl = host
|
||||
apiService.apiKey = apiKey
|
||||
_state.value = initialState.copy(
|
||||
host = host,
|
||||
apiKey = apiKey
|
||||
)
|
||||
monitorChanges()
|
||||
} else {
|
||||
launch {
|
||||
connect("pi.hole")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dispatch(action: Action) {
|
||||
when (action) {
|
||||
is Action.Authenticate -> {
|
||||
when (action.authString) {
|
||||
// The Pi-hole API key is just the web password hashed twice with SHA-256
|
||||
is AuthenticationString.Password -> authenticate(
|
||||
action.authString.value.hash().hash()
|
||||
)
|
||||
is AuthenticationString.Token -> authenticate(action.authString.value)
|
||||
}
|
||||
}
|
||||
is Action.Connect -> connect(action.host)
|
||||
is Action.Disable -> disable(action.duration)
|
||||
Action.Enable -> enable()
|
||||
Action.Forget -> forget()
|
||||
is Action.Scan -> scan(action.deviceIp)
|
||||
Action.About -> _state.value = _state.value.copy(showAbout = true)
|
||||
Action.Back -> back()
|
||||
}
|
||||
}
|
||||
|
||||
private fun back() {
|
||||
when {
|
||||
_state.value.showAbout -> {
|
||||
_state.value = _state.value.copy(showAbout = false)
|
||||
}
|
||||
_state.value.status != null -> {
|
||||
launch {
|
||||
_effects.emit(Effect.Exit)
|
||||
}
|
||||
}
|
||||
_state.value.scanning != null -> {
|
||||
_state.value = _state.value.copy(scanning = null)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forget() {
|
||||
_state.value = State()
|
||||
settings.remove(KEY_HOST)
|
||||
settings.remove(KEY_API_KEY)
|
||||
monitorJob?.cancel("")
|
||||
monitorJob = null
|
||||
}
|
||||
|
||||
private fun scan(startingIp: String) {
|
||||
scanJob = launch {
|
||||
val subnet = startingIp.substringBeforeLast(".")
|
||||
for (i in 0..255) {
|
||||
try {
|
||||
val ip = "$subnet.$i"
|
||||
_state.value = _state.value.copy(scanning = ip)
|
||||
apiService.baseUrl = ip
|
||||
apiService.getVersion()
|
||||
_state.value = _state.value.copy(scanning = null)
|
||||
scanJob = null
|
||||
return@launch
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
}
|
||||
_state.value = _state.value.copy(scanning = null)
|
||||
_effects.emit(Effect.Error("Failed to discover pi-hole on network"))
|
||||
scanJob = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect(host: String) {
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
launch {
|
||||
apiService.baseUrl = host
|
||||
try {
|
||||
apiService.getVersion()
|
||||
settings[KEY_HOST] = host
|
||||
_state.value = _state.value.copy(
|
||||
host = host,
|
||||
loading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(loading = false)
|
||||
_effects.emit(Effect.Error(e.message ?: "Failed to connect to $host"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticate(token: String) {
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
launch {
|
||||
apiService.apiKey = token
|
||||
try {
|
||||
apiService.getTopItems()
|
||||
settings[KEY_API_KEY] = token
|
||||
_state.value = _state.value.copy(
|
||||
apiKey = token,
|
||||
loading = false
|
||||
)
|
||||
monitorChanges()
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(loading = false)
|
||||
_effects.emit(Effect.Error(e.message ?: "Unable to authenticate with API key"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enable() {
|
||||
val loadingJob = launch {
|
||||
delay(500)
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
}
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disable(duration: Long?) {
|
||||
val loadingJob = launch {
|
||||
delay(500)
|
||||
_state.value = _state.value.copy(loading = true)
|
||||
}
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getStatus() {
|
||||
// Don't set the state to loading here, otherwise it'll cause blinking animations
|
||||
try {
|
||||
val summary = apiService.getSummary()
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun monitorChanges() {
|
||||
monitorJob = launch {
|
||||
while (isActive) {
|
||||
getStatus()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect fun String.hash(): String
|
|
@ -1,8 +0,0 @@
|
|||
package com.wbrawner.pihelper.shared
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.darwin.*
|
||||
|
||||
fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Darwin) {
|
||||
commonConfig()
|
||||
})
|
|
@ -0,0 +1,23 @@
|
|||
package com.wbrawner.pihelper.shared
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.darwin.*
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.CoreCrypto.CC_SHA256
|
||||
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
|
||||
|
||||
fun PiholeAPIService.Companion.create() = KtorPiholeAPIService(HttpClient(Darwin) {
|
||||
commonConfig()
|
||||
})
|
||||
|
||||
actual fun String.hash(): String {
|
||||
val digest = UByteArray(CC_SHA256_DIGEST_LENGTH)
|
||||
usePinned { inputPinned ->
|
||||
digest.usePinned { digestPinned ->
|
||||
CC_SHA256(inputPinned.addressOf(0), this.length.convert(), digestPinned.addressOf(0))
|
||||
}
|
||||
}
|
||||
return digest.joinToString(separator = "") { it.toString(16) }
|
||||
}
|
Loading…
Reference in a new issue