Migrate to redux-style store in shared module

This commit is contained in:
William Brawner 2022-03-12 21:23:35 -07:00
parent fc06a2f91d
commit a44e48d1e1
19 changed files with 452 additions and 409 deletions

View file

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

View file

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

View file

@ -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.",

View file

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

View file

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

View file

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

View file

@ -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) {
@ -123,4 +156,10 @@ fun LoadingSpinner(animate: Boolean = false) {
@Preview
fun LoadingSpinner_Preview() {
LoadingSpinner()
}
fun NavController.navigateIfNotAlreadyThere(route: String) {
if (currentDestination?.route != route) {
navigate(route)
}
}

View file

@ -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,29 +58,21 @@ 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)
} else {
EnableControls(viewModel::enablePiHole)
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 { store.dispatch(Action.Enable) }
}
}
}
}
}
}
@ExperimentalAnimationApi
@Composable
@Preview
fun MainScreen_Preview() {
val navController = rememberNavController()
PihelperTheme(false) {
MainScreen(navController = navController)
}
}
@Composable
fun StatusLabel(status: Status) {
Row(
@ -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({})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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