From a44e48d1e182db43e18922a73b43d9b91bb29027 Mon Sep 17 00:00:00 2001 From: Billy Brawner Date: Sat, 12 Mar 2022 21:23:35 -0700 Subject: [PATCH] Migrate to redux-style store in shared module --- .idea/misc.xml | 4 + .../wbrawner/pihelper/AddPiHelperViewModel.kt | 142 ---------- .../java/com/wbrawner/pihelper/AddScreen.kt | 60 +++-- .../java/com/wbrawner/pihelper/AuthScreen.kt | 16 +- .../java/com/wbrawner/pihelper/Extensions.kt | 24 -- .../java/com/wbrawner/pihelper/InfoScreen.kt | 14 +- .../com/wbrawner/pihelper/MainActivity.kt | 71 +++-- .../java/com/wbrawner/pihelper/MainScreen.kt | 59 ++--- .../com/wbrawner/pihelper/PiHelperModule.kt | 32 +-- .../wbrawner/pihelper/PiHelperViewModel.kt | 53 ---- .../java/com/wbrawner/pihelper/ScanScreen.kt | 65 +---- gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 10 +- .../com/wbrawner/pihelper/shared/Android.kt | 15 ++ .../wbrawner/pihelper/shared/HttpClient.kt | 8 - .../pihelper/shared/PiholeAPIService.kt | 5 + .../com/wbrawner/pihelper/shared/Store.kt | 250 ++++++++++++++++++ .../wbrawner/pihelper/shared/HttpClient.kt | 8 - .../com/wbrawner/pihelper/shared/iOS.kt | 23 ++ 19 files changed, 452 insertions(+), 409 deletions(-) delete mode 100644 app/src/main/java/com/wbrawner/pihelper/AddPiHelperViewModel.kt delete mode 100644 app/src/main/java/com/wbrawner/pihelper/Extensions.kt delete mode 100644 app/src/main/java/com/wbrawner/pihelper/PiHelperViewModel.kt create mode 100644 shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt delete mode 100644 shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/HttpClient.kt create mode 100644 shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt delete mode 100644 shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/HttpClient.kt create mode 100644 shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/iOS.kt diff --git a/.idea/misc.xml b/.idea/misc.xml index a7c7cb9..4c155bf 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -28,6 +28,10 @@ + + + + diff --git a/app/src/main/java/com/wbrawner/pihelper/AddPiHelperViewModel.kt b/app/src/main/java/com/wbrawner/pihelper/AddPiHelperViewModel.kt deleted file mode 100644 index 51f377f..0000000 --- a/app/src/main/java/com/wbrawner/pihelper/AddPiHelperViewModel.kt +++ /dev/null @@ -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(null) - val errorMessage = MutableStateFlow(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): 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 - } -} diff --git a/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt b/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt index 5f11e0e..1e883f0 100644 --- a/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/AddScreen.kt @@ -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.", diff --git a/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt b/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt index 1478af6..b9368f7 100644 --- a/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/AuthScreen.kt @@ -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))) } } } diff --git a/app/src/main/java/com/wbrawner/pihelper/Extensions.kt b/app/src/main/java/com/wbrawner/pihelper/Extensions.kt deleted file mode 100644 index 6cdce19..0000000 --- a/app/src/main/java/com/wbrawner/pihelper/Extensions.kt +++ /dev/null @@ -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) } -} diff --git a/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt b/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt index c06a53e..79f1041 100644 --- a/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/InfoScreen.kt @@ -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") } } diff --git a/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt b/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt index 6f32627..6e97478 100644 --- a/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt +++ b/app/src/main/java/com/wbrawner/pihelper/MainActivity.kt @@ -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) + } } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt b/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt index 9de924d..a375632 100644 --- a/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/MainScreen.kt @@ -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({}) } diff --git a/app/src/main/java/com/wbrawner/pihelper/PiHelperModule.kt b/app/src/main/java/com/wbrawner/pihelper/PiHelperModule.kt index 17f517a..43bc100 100644 --- a/app/src/main/java/com/wbrawner/pihelper/PiHelperModule.kt +++ b/app/src/main/java/com/wbrawner/pihelper/PiHelperModule.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/PiHelperViewModel.kt b/app/src/main/java/com/wbrawner/pihelper/PiHelperViewModel.kt deleted file mode 100644 index a4a949b..0000000 --- a/app/src/main/java/com/wbrawner/pihelper/PiHelperViewModel.kt +++ /dev/null @@ -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) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/pihelper/ScanScreen.kt b/app/src/main/java/com/wbrawner/pihelper/ScanScreen.kt index b2e14ba..004cc34 100644 --- a/app/src/main/java/com/wbrawner/pihelper/ScanScreen.kt +++ b/app/src/main/java/com/wbrawner/pihelper/ScanScreen.kt @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a2e10f..b01f2b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 646afa2..d07df18 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) } } diff --git a/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt new file mode 100644 index 0000000..ae1d372 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/Android.kt @@ -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') diff --git a/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/HttpClient.kt b/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/HttpClient.kt deleted file mode 100644 index d7e7557..0000000 --- a/shared/src/androidMain/kotlin/com/wbrawner/pihelper/shared/HttpClient.kt +++ /dev/null @@ -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() -}) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt index 5d6ef28..80ece26 100644 --- a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt +++ b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/PiholeAPIService.kt @@ -32,6 +32,11 @@ fun HttpClientConfig.commonConfig() { isLenient = true }) } + install(HttpTimeout) { + requestTimeoutMillis = 500 + connectTimeoutMillis = 500 + socketTimeoutMillis = 500 + } } class KtorPiholeAPIService(val httpClient: HttpClient) : PiholeAPIService() { diff --git a/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt new file mode 100644 index 0000000..b82bdcf --- /dev/null +++ b/shared/src/commonMain/kotlin/com/wbrawner/pihelper/shared/Store.kt @@ -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 + private val _effects = MutableSharedFlow() + val effects: Flow = _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 \ No newline at end of file diff --git a/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/HttpClient.kt b/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/HttpClient.kt deleted file mode 100644 index 32fe20b..0000000 --- a/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/HttpClient.kt +++ /dev/null @@ -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() -}) \ No newline at end of file diff --git a/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/iOS.kt b/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/iOS.kt new file mode 100644 index 0000000..e1901d5 --- /dev/null +++ b/shared/src/iosMain/Kotlin/com/wbrawner/pihelper/shared/iOS.kt @@ -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) } +} \ No newline at end of file