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-1646331413004.xml" value="0.4506079027355623" />
|
||||||
<entry key="../../../../layout/compose-model-1646332602415.xml" value="0.13775083612040134" />
|
<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-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_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-v26/ic_shortcut_pause.xml" value="0.27447916666666666" />
|
||||||
<entry key="app/src/main/res/drawable/background_splash.xml" value="0.409375" />
|
<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
|
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.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.OutlinedTextField
|
import androidx.compose.material.OutlinedTextField
|
||||||
import androidx.compose.material.Text
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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 com.wbrawner.pihelper.ui.PihelperTheme
|
||||||
import kotlinx.coroutines.launch
|
import java.net.Inet4Address
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) {
|
fun AddScreen(store: Store) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val context = LocalContext.current
|
||||||
val loadingMessage by addPiHelperViewModel.loadingMessage.collectAsState()
|
|
||||||
AddPiholeForm(
|
AddPiholeForm(
|
||||||
scanNetwork = { navController.navigate(Screens.SCAN.route) },
|
scanNetwork = {
|
||||||
connectToPihole = {
|
if (BuildConfig.DEBUG && Build.MODEL == "Android SDK built for x86") {
|
||||||
coroutineScope.launch {
|
// For emulators, just begin scanning the host machine directly
|
||||||
if (addPiHelperViewModel.connectToIpAddress(it)) {
|
store.dispatch(Action.Scan("10.0.2.2"))
|
||||||
Log.d("AddScreen", "Connected, going to auth")
|
return@AddPiholeForm
|
||||||
navController.navigate(Screens.AUTH.route)
|
}
|
||||||
|
(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(
|
fun AddPiholeForm(
|
||||||
scanNetwork: () -> Unit,
|
scanNetwork: () -> Unit,
|
||||||
connectToPihole: (String) -> Unit,
|
connectToPihole: (String) -> Unit,
|
||||||
loadingMessage: String? = null
|
loading: Boolean = false
|
||||||
) {
|
) {
|
||||||
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
|
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
|
||||||
Column(
|
Column(
|
||||||
|
@ -50,7 +76,7 @@ fun AddPiholeForm(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||||
) {
|
) {
|
||||||
LoadingSpinner(loadingMessage != null)
|
LoadingSpinner(loading)
|
||||||
Text(
|
Text(
|
||||||
text = "If you're not sure what the IP address for your Pi-hole is, Pi-helper can " +
|
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.",
|
"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.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) {
|
fun AuthScreen(store: Store) {
|
||||||
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
|
val (password: String, setPassword: (String) -> Unit) = remember { mutableStateOf("") }
|
||||||
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
|
val (apiKey: String, setApiKey: (String) -> Unit) = remember { mutableStateOf("") }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
@ -53,11 +55,7 @@ fun AuthScreen(navController: NavController, addPiHelperViewModel: AddPiHelperVi
|
||||||
visualTransformation = PasswordVisualTransformation()
|
visualTransformation = PasswordVisualTransformation()
|
||||||
)
|
)
|
||||||
PrimaryButton(text = "Authenticate with Password") {
|
PrimaryButton(text = "Authenticate with Password") {
|
||||||
coroutineScope.launch {
|
store.dispatch(Action.Authenticate(AuthenticationString.Password(password)))
|
||||||
if (addPiHelperViewModel.authenticateWithPassword(password)) {
|
|
||||||
navController.navigate(Screens.MAIN.route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
OrDivider()
|
OrDivider()
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
@ -69,9 +67,7 @@ fun AuthScreen(navController: NavController, addPiHelperViewModel: AddPiHelperVi
|
||||||
)
|
)
|
||||||
PrimaryButton(text = "Authenticate with API Key") {
|
PrimaryButton(text = "Authenticate with API Key") {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (addPiHelperViewModel.authenticateWithApiKey(password)) {
|
store.dispatch(Action.Authenticate(AuthenticationString.Token(apiKey)))
|
||||||
navController.navigate(Screens.MAIN.route)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat.startActivity
|
import com.wbrawner.pihelper.shared.Action
|
||||||
import androidx.navigation.NavController
|
import com.wbrawner.pihelper.shared.Store
|
||||||
import androidx.navigation.NavOptions
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfoScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) {
|
fun InfoScreen(store: Store) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
|
@ -77,11 +75,7 @@ fun InfoScreen(navController: NavController, addPiHelperViewModel: AddPiHelperVi
|
||||||
uriHandler.openUri(annotation.item)
|
uriHandler.openUri(annotation.item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextButton(onClick = {
|
TextButton(onClick = { store.dispatch(Action.Forget) }) {
|
||||||
addPiHelperViewModel.forgetPihole()
|
|
||||||
navController.navigate(Screens.ADD.route)
|
|
||||||
navController.backQueue.clear()
|
|
||||||
}) {
|
|
||||||
Text(text = "Forget Pi-hole")
|
Text(text = "Forget Pi-hole")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowInsetsController
|
import android.view.WindowInsetsController
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
|
@ -13,23 +14,31 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.wbrawner.pihelper.shared.Action
|
||||||
|
import com.wbrawner.pihelper.shared.Effect
|
||||||
|
import com.wbrawner.pihelper.shared.Store
|
||||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ExperimentalAnimationApi
|
@ExperimentalAnimationApi
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
@Inject
|
||||||
|
lateinit var store: Store
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -37,7 +46,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
LaunchedEffect(key1 = isDarkTheme) {
|
LaunchedEffect(key1 = isDarkTheme) {
|
||||||
if (isDarkTheme) return@LaunchedEffect
|
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) {
|
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||||
window.insetsController?.setSystemBarsAppearance(
|
window.insetsController?.setSystemBarsAppearance(
|
||||||
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
|
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
|
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 navController = rememberNavController()
|
||||||
val addPiHoleViewModel: AddPiHelperViewModel = viewModel()
|
|
||||||
val startDestination = when {
|
val startDestination = when {
|
||||||
addPiHoleViewModel.baseUrl == null -> {
|
state.host == null -> {
|
||||||
Screens.ADD
|
Screens.ADD
|
||||||
}
|
}
|
||||||
addPiHoleViewModel.apiKey == null -> {
|
state.apiKey == null -> {
|
||||||
Screens.AUTH
|
Screens.AUTH
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Screens.MAIN
|
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 {
|
PihelperTheme {
|
||||||
NavHost(navController, startDestination = startDestination.route) {
|
NavHost(navController, startDestination = startDestination.route) {
|
||||||
composable(Screens.ADD.route) {
|
composable(Screens.ADD.route) {
|
||||||
AddScreen(navController, addPiHoleViewModel)
|
AddScreen(store)
|
||||||
}
|
}
|
||||||
composable(Screens.SCAN.route) {
|
composable(Screens.SCAN.route) {
|
||||||
ScanScreen(navController, addPiHoleViewModel)
|
ScanScreen(store)
|
||||||
}
|
}
|
||||||
composable(Screens.AUTH.route) {
|
composable(Screens.AUTH.route) {
|
||||||
AuthScreen(
|
AuthScreen(store)
|
||||||
navController = navController,
|
|
||||||
addPiHelperViewModel = addPiHoleViewModel
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(Screens.MAIN.route) {
|
composable(Screens.MAIN.route) {
|
||||||
MainScreen(navController = navController)
|
MainScreen(store)
|
||||||
}
|
}
|
||||||
composable(Screens.INFO.route) {
|
composable(Screens.INFO.route) {
|
||||||
InfoScreen(
|
InfoScreen(store)
|
||||||
navController = navController,
|
|
||||||
addPiHelperViewModel = addPiHoleViewModel
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onBackPressed() {
|
||||||
|
store.dispatch(Action.Back)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class Screens(val route: String) {
|
enum class Screens(val route: String) {
|
||||||
|
@ -124,3 +157,9 @@ fun LoadingSpinner(animate: Boolean = false) {
|
||||||
fun LoadingSpinner_Preview() {
|
fun LoadingSpinner_Preview() {
|
||||||
LoadingSpinner()
|
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.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import com.wbrawner.pihelper.shared.Action
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import com.wbrawner.pihelper.shared.Status
|
import com.wbrawner.pihelper.shared.Status
|
||||||
|
import com.wbrawner.pihelper.shared.Store
|
||||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
@ -27,11 +29,8 @@ import kotlin.math.roundToLong
|
||||||
|
|
||||||
@ExperimentalAnimationApi
|
@ExperimentalAnimationApi
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(navController: NavController, viewModel: PiHelperViewModel = hiltViewModel()) {
|
fun MainScreen(store: Store) {
|
||||||
LaunchedEffect(key1 = viewModel) {
|
val state = store.state.collectAsState()
|
||||||
viewModel.monitorSummary()
|
|
||||||
}
|
|
||||||
val status by viewModel.status.collectAsState()
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
@ -40,7 +39,7 @@ fun MainScreen(navController: NavController, viewModel: PiHelperViewModel = hilt
|
||||||
contentColor = MaterialTheme.colors.onBackground,
|
contentColor = MaterialTheme.colors.onBackground,
|
||||||
elevation = 0.dp,
|
elevation = 0.dp,
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { navController.navigate(Screens.INFO.route) }) {
|
IconButton(onClick = { store.dispatch(Action.About) }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Settings,
|
imageVector = Icons.Default.Settings,
|
||||||
contentDescription = "Settings",
|
contentDescription = "Settings",
|
||||||
|
@ -59,27 +58,19 @@ fun MainScreen(navController: NavController, viewModel: PiHelperViewModel = hilt
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
LoadingSpinner(status == Status.LOADING)
|
LoadingSpinner(state.value.loading)
|
||||||
AnimatedVisibility(visible = status != Status.LOADING) {
|
AnimatedVisibility(visible = !state.value.loading) {
|
||||||
StatusLabel(status)
|
state.value.status?.let {
|
||||||
if (status == Status.ENABLED) {
|
StatusLabel(it)
|
||||||
DisableControls(viewModel)
|
if (it == Status.ENABLED) {
|
||||||
|
DisableControls { duration -> store.dispatch(Action.Disable(duration)) }
|
||||||
} else {
|
} else {
|
||||||
EnableControls(viewModel::enablePiHole)
|
EnableControls { store.dispatch(Action.Enable) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalAnimationApi
|
|
||||||
@Composable
|
|
||||||
@Preview
|
|
||||||
fun MainScreen_Preview() {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
PihelperTheme(false) {
|
|
||||||
MainScreen(navController = navController)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -121,7 +112,7 @@ fun EnableControls(onClick: () -> Unit) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DisableControls(viewModel: PiHelperViewModel = hiltViewModel()) {
|
fun DisableControls(disable: (duration: Long?) -> Unit) {
|
||||||
val (dialogVisible, setDialogVisible) = remember { mutableStateOf(false) }
|
val (dialogVisible, setDialogVisible) = remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
@ -130,13 +121,13 @@ fun DisableControls(viewModel: PiHelperViewModel = hiltViewModel()) {
|
||||||
.padding(start = 16.dp, top = 48.dp, end = 16.dp),
|
.padding(start = 16.dp, top = 48.dp, end = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
|
||||||
) {
|
) {
|
||||||
PrimaryButton("Disable for 10 seconds") { viewModel.disablePiHole(10) }
|
PrimaryButton("Disable for 10 seconds") { disable(10) }
|
||||||
PrimaryButton("Disable for 30 seconds") { viewModel.disablePiHole(30) }
|
PrimaryButton("Disable for 30 seconds") { disable(30) }
|
||||||
PrimaryButton("Disable for 5 minutes") { viewModel.disablePiHole(300) }
|
PrimaryButton("Disable for 5 minutes") { disable(300) }
|
||||||
PrimaryButton("Disable for custom time") { setDialogVisible(true) }
|
PrimaryButton("Disable for custom time") { setDialogVisible(true) }
|
||||||
PrimaryButton("Disable permanently") { viewModel.disablePiHole() }
|
PrimaryButton("Disable permanently") { disable(null) }
|
||||||
CustomTimeDialog(dialogVisible, setDialogVisible) {
|
CustomTimeDialog(dialogVisible, setDialogVisible) {
|
||||||
viewModel.disablePiHole(it)
|
disable(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -325,5 +316,5 @@ fun EnableControls_DarkPreview() {
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
fun DisableControls_Preview() {
|
fun DisableControls_Preview() {
|
||||||
DisableControls()
|
DisableControls({})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,42 +1,24 @@
|
||||||
package com.wbrawner.pihelper
|
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.PiholeAPIService
|
||||||
|
import com.wbrawner.pihelper.shared.Store
|
||||||
import com.wbrawner.pihelper.shared.create
|
import com.wbrawner.pihelper.shared.create
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Named
|
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
const val ENCRYPTED_SHARED_PREFS_FILE_NAME = "pihelper.prefs"
|
|
||||||
const val NAME_BASE_URL = "baseUrl"
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object PiHelperModule {
|
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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun providesPiholeAPIService(): PiholeAPIService = PiholeAPIService.create()
|
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
|
package com.wbrawner.pihelper
|
||||||
|
|
||||||
import android.content.Context
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import android.net.ConnectivityManager
|
import androidx.compose.foundation.layout.Column
|
||||||
import android.net.NetworkCapabilities
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import android.os.Build
|
import androidx.compose.foundation.layout.padding
|
||||||
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.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import com.wbrawner.pihelper.shared.Store
|
||||||
import com.wbrawner.pihelper.ui.PihelperTheme
|
import com.wbrawner.pihelper.ui.PihelperTheme
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.net.Inet4Address
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScanScreen(navController: NavController, addPiHelperViewModel: AddPiHelperViewModel) {
|
fun ScanScreen(store: Store) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
ScanningStatus(store.state.value.scanning?.let { "Scanning $it..." })
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScanningStatus(
|
fun ScanningStatus(
|
||||||
loadingMessage: String? = null
|
loadingMessage: String? = null
|
||||||
) {
|
) {
|
||||||
val (host: String, setHost: (String) -> Unit) = remember { mutableStateOf("pi.hole") }
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
|
|
|
@ -16,6 +16,7 @@ moshi = "1.9.2"
|
||||||
minSdk = "23"
|
minSdk = "23"
|
||||||
navigation = "2.4.1"
|
navigation = "2.4.1"
|
||||||
okhttp = "4.2.2"
|
okhttp = "4.2.2"
|
||||||
|
settings = "0.8.1"
|
||||||
versionCode = "1"
|
versionCode = "1"
|
||||||
versionName = "1.0"
|
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-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" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||||
|
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
|
||||||
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
|
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
|
||||||
moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||||
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
|
navigation-compose = { module = "androidx.navigation:navigation-compose", version = "navigation" }
|
||||||
|
|
|
@ -6,11 +6,10 @@ plugins {
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
android()
|
android()
|
||||||
listOf(
|
val iosX64 = iosX64()
|
||||||
iosX64(),
|
val iosArm64 = iosArm64()
|
||||||
iosArm64(),
|
val iosSimulatorArm64 = iosSimulatorArm64()
|
||||||
iosSimulatorArm64()
|
listOf(iosX64, iosArm64, iosSimulatorArm64).forEach {
|
||||||
).forEach {
|
|
||||||
it.binaries.framework {
|
it.binaries.framework {
|
||||||
baseName = "Pihelper"
|
baseName = "Pihelper"
|
||||||
}
|
}
|
||||||
|
@ -25,6 +24,7 @@ kotlin {
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
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
|
isLenient = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 500
|
||||||
|
connectTimeoutMillis = 500
|
||||||
|
socketTimeoutMillis = 500
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() {
|
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