= remember {
+ spring(dampingRatio = Spring.DampingRatioMediumBouncy)
+ }
+
val defaultElevation by animateDpAsState(
targetValue = if (isActive) 4.dp else 2.dp,
- label = "FabElevation"
+ label = "FabElevation",
+ animationSpec = sizeAnimationSpec
+ )
+
+ val width by animateDpAsState(
+ targetValue = if (isPressed || internalPressed) size + 4.dp else size,
+ label = "FabWidth",
+ animationSpec = sizeAnimationSpec
)
FloatingActionButton(
- modifier = modifier,
- onClick = { onToggle(!isActive) },
+ modifier = modifier.width(width),
+ onClick = {
+ onToggle(!isActive)
+ internalPressed = !internalPressed
+ coroutineScope.launch {
+ delay(150L)
+ internalPressed = !internalPressed
+ }
+ },
containerColor = containerColor,
+ interactionSource = interactionSource,
+ shape = RoundedCornerShape(cornerRadius.value),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = defaultElevation,
pressedElevation = defaultElevation * 2
- ),
- shape = CircleShape,
+ )
) {
AnimatedContent(
targetState = isActive,
@@ -94,14 +132,19 @@ internal fun ActionToggleButton(
MaterialTheme.colorScheme.onSurfaceVariant
}
- Icon(
- painter = if (isCurrentlyActive) iconOnActive else iconOnInactive,
- contentDescription = stringResource(
- R.string.toggle_format,
- contentDescription.orEmpty()
- ),
- tint = iconTint
- )
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = if (isCurrentlyActive) iconOnActive else iconOnInactive,
+ contentDescription = stringResource(
+ R.string.toggle_format,
+ contentDescription.orEmpty()
+ ),
+ tint = iconTint
+ )
+ }
}
}
}
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamLandscapeContent.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamLandscapeContent.kt
index 7a40198..efaab0c 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamLandscapeContent.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamLandscapeContent.kt
@@ -6,13 +6,17 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.displayCutoutPadding
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
@@ -26,6 +30,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
@@ -40,8 +45,8 @@ import androidx.compose.ui.unit.dp
import com.androidvip.sysctlgui.R
import com.androidvip.sysctlgui.design.theme.SysctlGuiTheme
import com.androidvip.sysctlgui.domain.enums.CommitMode
-import com.androidvip.sysctlgui.domain.models.ParamDocumentation
import com.androidvip.sysctlgui.models.UiKernelParam
+import com.androidvip.sysctlgui.models.UiParamDocumentation
import com.androidvip.sysctlgui.ui.components.ErrorContainer
import com.androidvip.sysctlgui.utils.performHapticFeedbackForToggle
import kotlinx.coroutines.launch
@@ -64,29 +69,38 @@ internal fun EditParamLandscapeContent(
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
+ val clipLabelText = stringResource(R.string.kernel_params)
+ val toastCopiedText = stringResource(R.string.copied_to_clipboard)
+
val copyParamContentToClipboard = {
val clipData = ClipData.newPlainText(
- context.getString(R.string.kernel_params),
- "${param.lastNameSegment}=${param.value} (${param.path})"
+ clipLabelText, "${param.lastNameSegment}=${param.value} (${param.path})"
)
val clipEntry = ClipEntry(clipData)
coroutineScope.launch {
clipboardManager.setClipEntry(clipEntry)
}
- Toast.makeText(
- context,
- context.getString(R.string.copied_to_clipboard),
- Toast.LENGTH_SHORT
- ).show()
+ Toast.makeText(context, toastCopiedText, Toast.LENGTH_SHORT).show()
}
- Row {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .displayCutoutPadding()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
Column(
modifier = Modifier
.weight(1f)
- .background(MaterialTheme.colorScheme.background)
+ .clip(RoundedCornerShape(24.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
) {
+ val toastCopyMessage = stringResource(R.string.long_press_to_copy)
Text(
text = param.lastNameSegment,
style = MaterialTheme.typography.displayMedium,
@@ -98,25 +112,18 @@ internal fun EditParamLandscapeContent(
onClick = {
Toast.makeText(
context,
- context.getString(R.string.long_press_to_copy),
+ toastCopyMessage,
Toast.LENGTH_SHORT
).show()
},
onLongClick = copyParamContentToClipboard
- )
- .padding(start = 16.dp, end = 16.dp, top = 24.dp),
+ ),
maxLines = 3,
color = MaterialTheme.colorScheme.onBackground,
overflow = TextOverflow.Ellipsis
)
- Row(
- modifier = Modifier.padding(
- horizontal = 16.dp,
- vertical = if (param.isTaskerParam) 0.dp else 24.dp
- ),
- verticalAlignment = Alignment.CenterVertically
- ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = param.name,
@@ -138,22 +145,18 @@ internal fun EditParamLandscapeContent(
if (state.taskerAvailable) {
TaskerButton(
- isTaskerParam = param.isTaskerParam,
- onToggle = { newState ->
+ isTaskerParam = param.isTaskerParam, onToggle = { newState ->
performHapticFeedbackForToggle(newState, view)
onTaskerClicked(newState)
- },
- modifier = Modifier.scale(0.85f)
+ }, modifier = Modifier.scale(0.85f)
)
}
FavoriteButton(
- isFavorite = param.isFavorite,
- onFavoriteClick = { newState ->
+ isFavorite = param.isFavorite, onFavoriteClick = { newState ->
performHapticFeedbackForToggle(newState, view)
onFavoriteToggle(newState)
- },
- modifier = Modifier.scale(0.85f)
+ }, modifier = Modifier.scale(0.85f)
)
}
@@ -161,7 +164,6 @@ internal fun EditParamLandscapeContent(
val listName = taskerListNameResolver(param.taskerList)
AssistChip(
onClick = { onTaskerClicked(true) },
- modifier = Modifier.padding(16.dp),
label = { Text(text = stringResource(R.string.tasker_list_format, listName)) },
leadingIcon = {
Icon(
@@ -169,19 +171,20 @@ internal fun EditParamLandscapeContent(
contentDescription = stringResource(R.string.tasker_list),
tint = MaterialTheme.colorScheme.tertiary
)
- }
- )
+ })
}
}
Column(
modifier = Modifier
.weight(1f)
+ .clip(RoundedCornerShape(24.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
) {
ParamValueContent(
- modifier = Modifier.padding(16.dp),
param = param,
keyboardType = state.keyboardType,
onValueApply = onValueApply
@@ -189,13 +192,11 @@ internal fun EditParamLandscapeContent(
AnimatedVisibility(
visible = showError && errorMessage.isNotEmpty(),
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
) {
ErrorContainer(message = errorMessage, onAnimationEnd = onErrorAnimationEnd)
}
ParamDocs(
- modifier = Modifier.padding(16.dp),
documentation = state.documentation,
onReadMorePressed = onDocsReadMorePressed
)
@@ -207,8 +208,7 @@ internal fun EditParamLandscapeContent(
@Preview(device = "spec:parent=pixel_5,orientation=landscape")
private fun EditParamContentPreview() {
- @Language("html")
- val htmlDocs = """
+ @Language("html") val htmlDocs = """
Correctable memory errors are very common on servers.
Soft-offline is kernel’s solution for memory pages having
(excessive) corrected memory errors.
@@ -220,12 +220,10 @@ private fun EditParamContentPreview() {
For a page that is part of a HugeTLB hugepage, soft-offline first migrates the entire HugeTLB hugepage, during which a free hugepage will be consumed as migration target. Then the original hugepage is dissolved into raw pages without compensation, reducing the capacity of the HugeTLB pool by 1.
It is user’s call to choose between reliability (staying away from fragile physical memory) vs performance / capacity implications in transparent and HugeTLB cases.
- """.trimIndent()
- .replace(
+ """.trimIndent().replace(
"",
""
- )
- .replace("", "")
+ ).replace("", "")
var showError by remember { mutableStateOf(true) }
@@ -243,7 +241,7 @@ private fun EditParamContentPreview() {
),
taskerAvailable = true,
keyboardType = KeyboardType.Number,
- documentation = ParamDocumentation(
+ documentation = UiParamDocumentation(
title = "vm.enable_soft_offline",
documentationText = "",
documentationHtml = htmlDocs,
@@ -253,15 +251,12 @@ private fun EditParamContentPreview() {
EditParamLandscapeContent(
state = state,
showError = showError,
- errorMessage = "Sysctl command for 'wm.swappiness' executed, " +
- "but output did not confirm the change. Output: 'Access denied'. " +
- "Try using '${CommitMode.ECHO}' mode.",
+ errorMessage = "Sysctl command for 'wm.swappiness' executed, " + "but output did not confirm the change. Output: 'Access denied'. " + "Try using '${CommitMode.ECHO}' mode.",
onValueApply = {},
onTaskerClicked = {},
onDocsReadMorePressed = {},
onFavoriteToggle = {},
- onErrorAnimationEnd = { showError = false }
- )
+ onErrorAnimationEnd = { showError = false })
}
}
}
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamScreen.kt
index f3256dc..5e87559 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamScreen.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamScreen.kt
@@ -24,13 +24,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -48,6 +48,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
@@ -73,8 +74,8 @@ import com.androidvip.sysctlgui.R
import com.androidvip.sysctlgui.design.theme.SysctlGuiTheme
import com.androidvip.sysctlgui.design.utils.isLandscape
import com.androidvip.sysctlgui.domain.enums.CommitMode
-import com.androidvip.sysctlgui.domain.models.ParamDocumentation
import com.androidvip.sysctlgui.models.UiKernelParam
+import com.androidvip.sysctlgui.models.UiParamDocumentation
import com.androidvip.sysctlgui.ui.components.ErrorContainer
import com.androidvip.sysctlgui.ui.components.SingleChoiceDialog
import com.androidvip.sysctlgui.ui.main.MainViewEffect
@@ -95,7 +96,7 @@ fun EditParamScreen(
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
- val state = viewModel.uiState.collectAsStateWithLifecycle()
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
val taskerListOptions = stringArrayResource(R.array.tasker_lists).toList()
var showSelectTaskerListDialog by rememberSaveable { mutableStateOf(false) }
var selectedOptionIndex by rememberSaveable {
@@ -153,7 +154,7 @@ fun EditParamScreen(
if (isLandscape()) {
EditParamLandscapeContent(
- state = state.value,
+ state = state,
showError = showError,
errorMessage = errorMessage,
onValueApply = {
@@ -176,7 +177,7 @@ fun EditParamScreen(
)
} else {
EditParamContent(
- state = state.value,
+ state = state,
showError = showError,
errorMessage = errorMessage,
onValueApply = {
@@ -228,64 +229,56 @@ private fun EditParamContent(
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
val scrollState = rememberScrollState()
+ val clipLabel = stringResource(R.string.kernel_params)
+ val toastMessage = stringResource(R.string.copied_to_clipboard)
val copyParamContentToClipboard = {
val clipData = ClipData.newPlainText(
- context.getString(R.string.kernel_params),
+ clipLabel,
"${param.lastNameSegment}=${param.value} (${param.path})"
)
val clipEntry = ClipEntry(clipData)
coroutineScope.launch {
clipboardManager.setClipEntry(clipEntry)
}
- Toast.makeText(
- context,
- context.getString(R.string.copied_to_clipboard),
- Toast.LENGTH_SHORT
- ).show()
+ Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show()
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
- .background(MaterialTheme.colorScheme.surfaceContainer)
+ .padding(vertical = 24.dp, horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
- .background(MaterialTheme.colorScheme.background)
+ .clip(RoundedCornerShape(24.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
) {
+ val toastCopyMessage = stringResource(R.string.long_press_to_copy)
Text(
text = param.lastNameSegment,
- style = MaterialTheme.typography.displayLarge,
+ style = MaterialTheme.typography.displaySmall,
modifier = Modifier
.combinedClickable(
enabled = true,
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {
- Toast.makeText(
- context,
- context.getString(R.string.long_press_to_copy),
- Toast.LENGTH_SHORT
- ).show()
+ Toast.makeText(context, toastCopyMessage, Toast.LENGTH_SHORT).show()
},
onLongClick = copyParamContentToClipboard
- )
- .padding(start = 16.dp, end = 16.dp, top = 64.dp),
+ ),
maxLines = 3,
- color = MaterialTheme.colorScheme.onBackground,
+ color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis
)
- Row(
- modifier = Modifier.padding(
- horizontal = 16.dp,
- vertical = if (param.isTaskerParam) 0.dp else 24.dp
- ),
- verticalAlignment = Alignment.CenterVertically
- ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = param.name,
@@ -330,7 +323,6 @@ private fun EditParamContent(
val listName = taskerListNameResolver(param.taskerList)
AssistChip(
onClick = { onTaskerClicked(true) },
- modifier = Modifier.padding(16.dp),
label = { Text(text = stringResource(R.string.tasker_list_format, listName)) },
leadingIcon = {
Icon(
@@ -344,21 +336,24 @@ private fun EditParamContent(
}
ParamValueContent(
- modifier = Modifier.padding(16.dp),
+ modifier = Modifier
+ .clip(RoundedCornerShape(24.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .padding(16.dp),
param = param,
keyboardType = state.keyboardType,
onValueApply = onValueApply
)
- AnimatedVisibility(
- visible = showError && errorMessage.isNotEmpty(),
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
- ) {
+ AnimatedVisibility(visible = showError && errorMessage.isNotEmpty(),) {
ErrorContainer(message = errorMessage, onAnimationEnd = onErrorAnimationEnd)
}
ParamDocs(
- modifier = Modifier.padding(16.dp),
+ modifier = Modifier
+ .clip(RoundedCornerShape(24.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainer)
+ .padding(16.dp),
documentation = state.documentation,
onReadMorePressed = onDocsReadMorePressed
)
@@ -381,8 +376,6 @@ fun ParamValueContent(
onBack = { isEditing = false }
)
- HorizontalDivider()
-
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
@@ -486,11 +479,9 @@ internal fun EditableParamValue(
@Composable
internal fun ParamDocs(
modifier: Modifier = Modifier,
- documentation: ParamDocumentation?,
+ documentation: UiParamDocumentation?,
onReadMorePressed: () -> Unit,
) {
- HorizontalDivider()
-
Column(modifier = modifier) {
Text(
text = stringResource(R.string.documentation),
@@ -540,13 +531,13 @@ internal fun ParamDocs(
@Composable
internal fun DocumentationContent(
- documentation: ParamDocumentation,
+ documentation: UiParamDocumentation,
onReadMorePressed: () -> Unit
) {
Column {
val documentationText = if (!documentation.documentationHtml.isNullOrEmpty()) {
AnnotatedString.fromHtml(
- htmlString = documentation.documentationHtml.orEmpty(),
+ htmlString = documentation.documentationHtml,
linkStyles = TextLinkStyles(
style = MaterialTheme.typography.bodyMedium.toSpanStyle().copy(
color = MaterialTheme.colorScheme.primary,
@@ -627,7 +618,7 @@ private fun EditParamContentPreview() {
),
taskerAvailable = true,
keyboardType = KeyboardType.Number,
- documentation = ParamDocumentation(
+ documentation = UiParamDocumentation(
title = "vm.enable_soft_offline",
documentationText = "",
documentationHtml = htmlDocs,
@@ -640,10 +631,10 @@ private fun EditParamContentPreview() {
errorMessage = "Sysctl command for 'wm.swappiness' executed, " +
"but output did not confirm the change. Output: 'Access denied'. " +
"Try using '${CommitMode.ECHO}' mode.",
- onValueApply = {},
- onTaskerClicked = {},
- onDocsReadMorePressed = {},
- onFavoriteToggle = {},
+ onValueApply = { },
+ onTaskerClicked = { },
+ onDocsReadMorePressed = { },
+ onFavoriteToggle = { },
onErrorAnimationEnd = { showError = false }
)
}
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamViewModel.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamViewModel.kt
index fc92295..9979e44 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamViewModel.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamViewModel.kt
@@ -17,6 +17,7 @@ import com.androidvip.sysctlgui.domain.usecase.GetUserParamByNameUseCase
import com.androidvip.sysctlgui.domain.usecase.IsTaskerInstalledUseCase
import com.androidvip.sysctlgui.domain.usecase.UpsertUserParamUseCase
import com.androidvip.sysctlgui.helpers.UiKernelParamMapper
+import com.androidvip.sysctlgui.models.toUiParamDocumentation
import com.androidvip.sysctlgui.utils.BaseViewModel
import com.androidvip.sysctlgui.widgets.UpdateFavoriteWidgetUseCase
import kotlinx.coroutines.launch
@@ -54,7 +55,7 @@ class EditParamViewModel(
val documentation = runCatching { getDocumentation(param) }.getOrNull()
setState {
- copy(documentation = documentation)
+ copy(documentation = documentation?.toUiParamDocumentation())
}
}
}
@@ -67,9 +68,13 @@ class EditParamViewModel(
is EditParamViewEvent.UndoRequested -> {
previousKernelParamValue?.let { applyKernelParam(it, true) }
}
+
is EditParamViewEvent.DocumentationReadMoreClicked -> onDocumentationReadMoreClicked()
is EditParamViewEvent.FavoriteTogglePressed -> onFavoriteTogglePressed(event.newState)
- is EditParamViewEvent.TaskerTogglePressed -> onTaskerTogglePressed(event.newState, event.listId)
+ is EditParamViewEvent.TaskerTogglePressed -> onTaskerTogglePressed(
+ event.newState,
+ event.listId
+ )
}
}
@@ -95,6 +100,7 @@ class EditParamViewModel(
is BlankValueNotAllowedException -> stringProvider.getString(
R.string.apply_error_blank_values
)
+
is CommitModeException -> stringProvider.getString(
R.string.apply_error_commit_mode
)
@@ -102,6 +108,7 @@ class EditParamViewModel(
is ApplyValueException -> stringProvider.getString(
R.string.apply_error_command_execution_failed
)
+
else -> it.message.orEmpty()
}
setEffect {
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamViewState.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamViewState.kt
index 549e388..139ed05 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamViewState.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/EditParamViewState.kt
@@ -1,14 +1,16 @@
package com.androidvip.sysctlgui.ui.params.edit
+import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.KeyboardType
-import com.androidvip.sysctlgui.domain.models.ParamDocumentation
import com.androidvip.sysctlgui.models.UiKernelParam
+import com.androidvip.sysctlgui.models.UiParamDocumentation
+@Immutable
data class EditParamViewState(
val kernelParam: UiKernelParam = UiKernelParam(),
val taskerAvailable: Boolean = false,
val keyboardType: KeyboardType = KeyboardType.Text,
- val documentation: ParamDocumentation? = null,
+ val documentation: UiParamDocumentation? = null,
)
sealed interface EditParamViewEffect {
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/ImportPresetScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/ImportPresetScreen.kt
index 5434991..993d11c 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/ImportPresetScreen.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/ImportPresetScreen.kt
@@ -17,10 +17,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -75,12 +77,13 @@ fun ImportPresetScreen(
) {
val context = LocalContext.current
val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val topBarTitle = stringResource(R.string.applying_preset)
LaunchedEffect(Unit) {
mainViewModel.onEvent(
MainViewEvent.OnSateChangeRequested(
MainViewState(
- topBarTitle = context.getString(R.string.applying_preset),
+ topBarTitle = topBarTitle,
showTopBar = true,
showNavBar = false,
showBackButton = true,
@@ -187,7 +190,7 @@ private fun IncomingPresetsContent(
) {
itemsIndexed(
items = paramsToImport,
- key = { index, item -> item.name }
+ key = { _, item -> item.name }
) { index, item ->
Row(
modifier = Modifier
@@ -253,6 +256,7 @@ private fun IncomingPresetsContent(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
+ .navigationBarsPadding()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally)
) {
@@ -285,7 +289,9 @@ private fun IncomingPresetsLandscapeContent(
onCancelPressed: () -> Unit
) {
Row(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .displayCutoutPadding(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
LazyColumn(
@@ -295,7 +301,7 @@ private fun IncomingPresetsLandscapeContent(
) {
itemsIndexed(
items = paramsToImport,
- key = { index, item -> item.name }
+ key = { _, item -> item.name }
) { index, item ->
Row(
modifier = Modifier
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/PresetsScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/PresetsScreen.kt
index 49462bd..f663aae 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/PresetsScreen.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/PresetsScreen.kt
@@ -65,7 +65,7 @@ fun PresetsScreen(
}
)
val createFileLauncher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.CreateDocument("*/*"),
+ contract = ActivityResultContracts.CreateDocument("text/plain"),
onResult = { uri ->
viewModel.onEvent(PresetsViewEvent.BackUpFileCreated(uri))
}
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/PresetsViewModel.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/PresetsViewModel.kt
index 9538f27..96111a0 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/PresetsViewModel.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/PresetsViewModel.kt
@@ -7,6 +7,7 @@ import com.androidvip.sysctlgui.R
import com.androidvip.sysctlgui.data.utils.PresetsFileProcessor
import com.androidvip.sysctlgui.domain.StringProvider
import com.androidvip.sysctlgui.domain.exceptions.EmptyFileException
+import com.androidvip.sysctlgui.domain.exceptions.InvalidFileException
import com.androidvip.sysctlgui.domain.exceptions.MalformedLineException
import com.androidvip.sysctlgui.domain.exceptions.NoValidParamException
import com.androidvip.sysctlgui.domain.usecase.AddUserParamsUseCase
@@ -77,6 +78,8 @@ class PresetsViewModel(
setEffect { PresetsViewEffect.ShowError(stringProvider.getString(R.string.export_error_no_param)) }
} catch (_: IOException) {
setEffect { PresetsViewEffect.ShowError(stringProvider.getString(R.string.export_error_io)) }
+ } catch (_: InvalidFileException) {
+ setEffect { PresetsViewEffect.ShowError(stringProvider.getString(R.string.import_error_invalid_type)) }
} catch (e: Exception) {
Log.e("PresetsViewModel", "Error importing file", e)
setEffect { PresetsViewEffect.ShowError(stringProvider.getString(R.string.import_error)) }
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchScreen.kt
index 86b15a4..d19d39f 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchScreen.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchScreen.kt
@@ -11,6 +11,10 @@ import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
@@ -41,6 +45,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -160,6 +165,12 @@ private fun SearchScreenContent(
onSearch = onSearch,
expanded = searchActive,
onExpandedChange = onActiveChange,
+ modifier = Modifier.padding(
+ start = WindowInsets.displayCutout.asPaddingValues()
+ .calculateStartPadding(
+ LocalLayoutDirection.current
+ )
+ ),
placeholder = { Text(stringResource(R.string.search_title)) },
leadingIcon = {
AnimatedContent(
@@ -181,7 +192,7 @@ private fun SearchScreenContent(
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = stringResource(
- R.string.acessibility_search_icon
+ R.string.accessibility_search_icon
)
)
}
@@ -193,9 +204,7 @@ private fun SearchScreenContent(
enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(),
exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut()
) {
- IconButton(onClick = {
- onSearchQueryChange("")
- }) {
+ IconButton(onClick = { onSearchQueryChange("") }) {
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = stringResource(R.string.clear_search)
@@ -266,7 +275,12 @@ private fun SearchViewContent(
LazyVerticalGrid(
columns = hintItemColumns,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ start = WindowInsets.displayCutout.asPaddingValues()
+ .calculateStartPadding(LocalLayoutDirection.current)
+ )
) {
if (historyHints.isNotEmpty()) {
item(
@@ -395,7 +409,7 @@ private fun SearchResultsContent(
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(bottom = 8.dp)
+ contentPadding = PaddingValues(bottom = 8.dp, top = 16.dp)
) {
itemsIndexed(searchResults) { index, param ->
ParamRow(
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchViewState.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchViewState.kt
index 282a131..436f1cd 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchViewState.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchViewState.kt
@@ -1,8 +1,10 @@
package com.androidvip.sysctlgui.ui.search
+import androidx.compose.runtime.Stable
import com.androidvip.sysctlgui.models.SearchHint
import com.androidvip.sysctlgui.models.UiKernelParam
+@Stable
data class SearchViewState(
val loading: Boolean = false,
val searchHints: List = emptyList(),
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsScreen.kt
index b7199db..a6bf3a8 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsScreen.kt
@@ -9,17 +9,23 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.GridItemSpan
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -28,7 +34,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -55,8 +63,8 @@ import com.androidvip.sysctlgui.ui.settings.components.SwitchSettingComponent
import com.androidvip.sysctlgui.ui.settings.components.TextSettingComponent
import com.androidvip.sysctlgui.ui.settings.model.SettingsViewEffect
import com.androidvip.sysctlgui.ui.settings.model.SettingsViewEvent
-import com.androidvip.sysctlgui.utils.browse
import org.koin.androidx.compose.koinViewModel
+import com.androidvip.sysctlgui.data.R as DataR
internal const val DISABLED_ALPHA = 0.38f
@@ -66,8 +74,10 @@ internal fun SettingsScreen(
viewModel: SettingsViewModel = koinViewModel(),
onNavigate: (UiRoute) -> Unit
) {
- val state = viewModel.uiState.collectAsStateWithLifecycle()
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
+ val uriHandler = LocalUriHandler.current
+ var showRevertChangesDialog by remember { mutableStateOf(false) }
var hasNotificationPermission by remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
mutableStateOf(
@@ -112,7 +122,7 @@ internal fun SettingsScreen(
}
is SettingsViewEffect.OpenBrowser -> {
- context.browse(effect.url)
+ uriHandler.openUri(effect.url)
}
is SettingsViewEffect.Navigate -> {
@@ -122,12 +132,16 @@ internal fun SettingsScreen(
is SettingsViewEffect.ShowToast -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
+
+ SettingsViewEffect.ShowRevertDialog -> {
+ showRevertChangesDialog = true
+ }
}
}
}
SettingsScreenContent(
- settings = state.value.settings,
+ settings = state.settings,
onSettingHeaderClicked = { appSetting ->
viewModel.onEvent(SettingsViewEvent.SettingHeaderClicked(appSetting))
},
@@ -135,6 +149,10 @@ internal fun SettingsScreen(
viewModel.onEvent(SettingsViewEvent.SettingValueChanged(appSetting, newValue))
}
)
+
+ if (showRevertChangesDialog) {
+ RevertChangesDialog(onDismissRequest = { showRevertChangesDialog = false })
+ }
}
@Composable
@@ -145,81 +163,50 @@ private fun SettingsScreenContent(
) {
val groupedSettings = settings.groupBy { it.category }
val columns = if (isLandscape()) 2 else 1
+ val containerColor = MaterialTheme.colorScheme.surfaceContainer
- LazyVerticalGrid(
- columns = GridCells.Fixed(columns),
- modifier = Modifier.fillMaxWidth()
+ LazyVerticalStaggeredGrid(
+ columns = StaggeredGridCells.Fixed(columns),
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalItemSpacing = 16.dp,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
- groupedSettings.forEach { (category, categorySettings) ->
- item(span = { GridItemSpan(columns) }) {
- Text(
- text = category,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary,
+ groupedSettings.forEach { (category, settingsGroup) ->
+ item {
+ Column(
modifier = Modifier
.fillMaxWidth()
- .padding(
- top = 8.dp,
- bottom = 8.dp,
- start = if (columns > 1) 16.dp else 56.dp,
- end = 16.dp,
- )
- )
- }
-
- items(
- items = categorySettings,
- key = { setting -> setting.key }
- ) { appSetting ->
- val itemModifier = if (columns > 1) {
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 4.dp)
- } else {
- Modifier.fillMaxWidth()
- }
+ .clip(RoundedCornerShape(24.dp))
+ .background(containerColor)
+ ) {
+ Text(
+ text = category,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 24.dp, bottom = 8.dp)
+ .padding(horizontal = 16.dp)
+ )
- when (appSetting.type) {
- SettingItemType.Text -> {
- HeaderComponent(
- modifier = itemModifier,
- appSetting = appSetting,
- onClick = { onSettingHeaderClicked(appSetting) }
- )
- }
- SettingItemType.List -> {
- TextSettingComponent(
- modifier = itemModifier,
- appSetting = appSetting,
- onValueChange = { newValue ->
- onValueChanged(appSetting, newValue)
- }
- )
- }
- SettingItemType.Switch -> {
- SwitchSettingComponent(
- modifier = itemModifier,
- appSetting = appSetting,
- onValueChange = { newValue ->
- onValueChanged(appSetting, newValue)
- }
- )
- }
- SettingItemType.Slider -> {
- SliderSettingComponent(
- modifier = itemModifier,
- appSetting = appSetting,
- onValueChange = { newValue ->
- onValueChanged(appSetting, newValue)
- }
- )
- }
+ SettingsGroupItem(
+ settingsGroup = settingsGroup,
+ columns = columns,
+ onSettingHeaderClicked = onSettingHeaderClicked,
+ onValueChanged = onValueChanged
+ )
}
}
}
- item(span = { GridItemSpan(columns) }) {
- Box(contentAlignment = Alignment.Center) {
+ item {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(24.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainer),
+ contentAlignment = Alignment.Center
+ ) {
Row(
modifier = Modifier.padding(all = 16.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -242,7 +229,7 @@ private fun SettingsScreenContent(
}
}
- item(span = { GridItemSpan(columns) }) {
+ item(span = StaggeredGridItemSpan.FullLine) {
Text(
text = "Developed with ❤️ by Lennoard Silva\nAndroid enthusiast 🤖",
style = MaterialTheme.typography.bodySmall,
@@ -251,7 +238,7 @@ private fun SettingsScreenContent(
modifier = Modifier
.fillMaxWidth()
.padding(
- top = 64.dp,
+ top = 64.dp,
bottom = 32.dp,
start = 24.dp,
end = 24.dp,
@@ -261,6 +248,93 @@ private fun SettingsScreenContent(
}
}
+@Composable
+private fun SettingsGroupItem(
+ settingsGroup: List>,
+ columns: Int,
+ onSettingHeaderClicked: (AppSetting<*>) -> Unit,
+ onValueChanged: (AppSetting<*>, Any) -> Unit
+) {
+ settingsGroup.forEachIndexed { index, appSetting ->
+ val itemModifier = if (columns > 1) {
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ } else {
+ Modifier.fillMaxWidth()
+ }
+
+ when (appSetting.type) {
+ SettingItemType.Text -> {
+ HeaderComponent(
+ modifier = itemModifier,
+ appSetting = appSetting,
+ onClick = { onSettingHeaderClicked(appSetting) }
+ )
+ }
+
+ SettingItemType.List -> {
+ TextSettingComponent(
+ modifier = itemModifier,
+ appSetting = appSetting,
+ onValueChange = { newValue ->
+ onValueChanged(appSetting, newValue)
+ }
+ )
+ }
+
+ SettingItemType.Switch -> {
+ SwitchSettingComponent(
+ modifier = itemModifier,
+ appSetting = appSetting,
+ onValueChange = { newValue ->
+ onValueChanged(appSetting, newValue)
+ }
+ )
+ }
+
+ SettingItemType.Slider -> {
+ SliderSettingComponent(
+ modifier = itemModifier,
+ appSetting = appSetting,
+ onValueChange = { newValue ->
+ onValueChanged(appSetting, newValue)
+ }
+ )
+ }
+ }
+
+ if (index == settingsGroup.lastIndex) {
+ Spacer(modifier = Modifier.size(8.dp))
+ }
+ }
+}
+
+
+@Composable
+fun RevertChangesDialog(onDismissRequest: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(text = stringResource(android.R.string.ok))
+ }
+ },
+ icon = {
+ Icon(
+ painter = painterResource(R.drawable.ic_restore_settings),
+ contentDescription = null
+ )
+ },
+ title = {
+ Text(text = stringResource(DataR.string.prefs_startup_revert_changes))
+ },
+ text = {
+ Text(text = stringResource(DataR.string.prefs_startup_revert_changes_message))
+ }
+ )
+}
+
@Composable
@PreviewLightDark
@Preview(device = "spec:parent=pixel_5,orientation=landscape")
@@ -308,7 +382,7 @@ internal fun SettingsScreenPreview() {
type = SettingItemType.List,
values = listOf(
CommitMode.SYSCTL.name.lowercase(),
- CommitMode.ECHO.name.lowercase(),
+ CommitMode.ECHO.name.lowercase()
)
),
@@ -322,7 +396,7 @@ internal fun SettingsScreenPreview() {
type = SettingItemType.Text,
)
)
- Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
+ Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) {
SettingsScreenContent(
settings = settings,
onSettingHeaderClicked = {},
@@ -330,4 +404,4 @@ internal fun SettingsScreenPreview() {
)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsViewModel.kt
index 412eb38..7d98640 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsViewModel.kt
@@ -11,6 +11,7 @@ import com.androidvip.sysctlgui.domain.enums.SettingItemType
import com.androidvip.sysctlgui.domain.models.KEY_CONTRIBUTORS
import com.androidvip.sysctlgui.domain.models.KEY_DELETE_HISTORY
import com.androidvip.sysctlgui.domain.models.KEY_MANAGE_PARAMS
+import com.androidvip.sysctlgui.domain.models.KEY_REVERT_CHANGES
import com.androidvip.sysctlgui.domain.models.KEY_SOURCE_CODE
import com.androidvip.sysctlgui.domain.models.KEY_TRANSLATIONS
import com.androidvip.sysctlgui.domain.usecase.GetAppSettingsUseCase
@@ -78,6 +79,16 @@ class SettingsViewModel(
is SettingsViewEvent.SettingHeaderClicked<*> -> {
when (event.appSetting.key) {
+ KEY_REVERT_CHANGES -> {
+ setEffect { SettingsViewEffect.ShowRevertDialog }
+ viewModelScope.launch {
+ sharedPreferences.edit(commit = true) {
+ putBoolean(Prefs.RunOnStartup.key, false)
+ }
+ loadSettings()
+ }
+ }
+
KEY_MANAGE_PARAMS -> {
setEffect { SettingsViewEffect.Navigate(UiRoute.UserParams) }
}
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/HeaderComponent.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/HeaderComponent.kt
index a19637d..7c43c14 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/HeaderComponent.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/HeaderComponent.kt
@@ -4,7 +4,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -42,8 +41,6 @@ fun HeaderComponent(
modifier = Modifier.size(24.dp)
)
}
- } else {
- Spacer(modifier = Modifier.size(24.dp))
}
SettingsComponentColumn(
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/SliderSettingComponent.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/SliderSettingComponent.kt
index c2e563b..65cfd1e 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/SliderSettingComponent.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/SliderSettingComponent.kt
@@ -12,7 +12,6 @@ import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -35,12 +34,14 @@ fun SliderSettingComponent(
modifier = modifier.padding(all = 16.dp),
horizontalArrangement = Arrangement.spacedBy(space = 16.dp)
) {
- Box(
- modifier = Modifier
- .align(Alignment.CenterVertically)
- .size(24.dp)
- ) {
- icon?.invoke()
+ if (icon != null) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .size(24.dp)
+ ) {
+ icon()
+ }
}
val values = appSetting.values?.filterIsInstance() ?: emptyList()
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/SwitchSettingComponent.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/SwitchSettingComponent.kt
index 09a1369..3542348 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/SwitchSettingComponent.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/SwitchSettingComponent.kt
@@ -41,12 +41,14 @@ fun SwitchSettingComponent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(space = 16.dp)
) {
- Box(
- modifier = Modifier
- .align(Alignment.CenterVertically)
- .size(24.dp)
- ) {
- icon?.invoke()
+ if (icon != null) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterVertically)
+ .size(24.dp)
+ ) {
+ icon()
+ }
}
SettingsComponentColumn(
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/TextSettingComponent.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/TextSettingComponent.kt
index ecf396d..b7ba48b 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/TextSettingComponent.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/components/TextSettingComponent.kt
@@ -5,7 +5,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -59,8 +58,6 @@ fun TextSettingComponent(
modifier = Modifier.size(24.dp)
)
}
- } else {
- Spacer(modifier = Modifier.size(24.dp))
}
SettingsComponentColumn(
@@ -99,9 +96,11 @@ fun TextSettingComponent(
@PreviewLightDark
private fun TextSettingComponentPreview() {
SysctlGuiTheme(dynamicColor = true) {
- Box(modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background)) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ ) {
TextSettingComponent(
appSetting = AppSetting(
key = "key",
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/model/SettingsViewEvent.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/model/SettingsViewEvent.kt
index 75e2859..a177a60 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/model/SettingsViewEvent.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/model/SettingsViewEvent.kt
@@ -13,6 +13,7 @@ sealed interface SettingsViewEffect {
class OpenBrowser(val url: String) : SettingsViewEffect
class Navigate(val route: UiRoute) : SettingsViewEffect
class ShowToast(val message: String) : SettingsViewEffect
+ data object ShowRevertDialog : SettingsViewEffect
}
data class SettingsViewState(
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/start/ConsentActivity.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/start/ConsentActivity.kt
new file mode 100644
index 0000000..76d02d8
--- /dev/null
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/start/ConsentActivity.kt
@@ -0,0 +1,254 @@
+package com.androidvip.sysctlgui.ui.start
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.displayCutoutPadding
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import com.androidvip.sysctlgui.R
+import com.androidvip.sysctlgui.design.theme.SysctlGuiTheme
+import com.androidvip.sysctlgui.design.utils.AnimatedStrikeThroughIcon
+import com.androidvip.sysctlgui.design.utils.StatusBarProtection
+import com.androidvip.sysctlgui.design.utils.isLandscape
+import com.androidvip.sysctlgui.domain.repository.AppPrefs
+import org.koin.android.ext.android.inject
+
+class ConsentActivity : ComponentActivity() {
+ private val prefs by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ SysctlGuiTheme {
+ ScreenContent(
+ onClosePressed = { finish() },
+ onContinuePressed = { granted ->
+ if (granted) startMainActivity() else finish()
+ }
+ )
+ StatusBarProtection()
+ }
+ }
+ }
+
+ private fun startMainActivity() {
+ prefs.consentGranted = true
+ val mainIntent = Intent(this, StartActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
+ putExtras(intent.extras ?: Bundle())
+ }
+ startActivity(mainIntent)
+ finish()
+ }
+
+ @Composable
+ private fun ScreenContent(
+ onContinuePressed: (granted: Boolean) -> Unit = {},
+ onClosePressed: () -> Unit = {}
+ ) {
+ var consentGranted by remember { mutableStateOf(false) }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ if (isLandscape()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .systemBarsPadding()
+ .displayCutoutPadding()
+ .padding(24.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ ConsentInfo(consentGranted)
+ }
+
+ Column(
+ modifier = Modifier.weight(1f),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ RevertInfoCard()
+ ConsentActions(
+ onContinuePressed = { onContinuePressed(consentGranted) },
+ onConsentChanged = { consentGranted = it },
+ onClosePressed = onClosePressed
+ )
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .systemBarsPadding()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.size(24.dp))
+ ConsentInfo(consentGranted)
+ Spacer(modifier = Modifier.size(24.dp))
+ RevertInfoCard()
+ ConsentActions(
+ onContinuePressed = { onContinuePressed(consentGranted) },
+ onConsentChanged = { consentGranted = it },
+ onClosePressed = onClosePressed
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun RevertInfoCard() {
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_restore_settings),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Text(
+ text = stringResource(R.string.consent_revert_info),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+
+ @Composable
+ private fun ConsentInfo(consentGranted: Boolean) {
+ AnimatedStrikeThroughIcon(
+ icon = ImageVector.vectorResource(R.drawable.ic_edit),
+ modifier = Modifier.size(92.dp),
+ isOff = !consentGranted,
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Text(
+ text = stringResource(R.string.consent_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 24.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.consent_message),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.padding(top = 16.dp)
+ )
+ }
+
+ @Composable
+ private fun ConsentActions(
+ modifier: Modifier = Modifier,
+ onContinuePressed: () -> Unit,
+ onClosePressed: () -> Unit,
+ onConsentChanged: (granted: Boolean) -> Unit
+ ) {
+ var checked by remember { mutableStateOf(false) }
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable { checked = !checked; onConsentChanged(checked) },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = { checked = it; onConsentChanged(it) }
+ )
+ Text(
+ text = stringResource(R.string.consent_checkbox),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ }
+
+ Button(
+ onClick = onContinuePressed,
+ enabled = checked,
+ modifier = Modifier.padding(top = 24.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.continue_),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+
+ FilledTonalButton(onClick = onClosePressed) {
+ Text(
+ text = stringResource(R.string.exit),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+
+ @Composable
+ @PreviewLightDark
+ @Preview(device = "spec:parent=pixel_5,orientation=landscape", showSystemUi = true)
+ private fun Preview() {
+ SysctlGuiTheme {
+ Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
+ ScreenContent()
+ }
+ StatusBarProtection()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/start/StartActivity.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/start/StartActivity.kt
index d173902..90d45d6 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/start/StartActivity.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/start/StartActivity.kt
@@ -27,13 +27,19 @@ class StartActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
- super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
splashScreen.setKeepOnScreenCondition { true }
binding = ActivitySplashBinding.inflate(layoutInflater)
setContentView(binding.root)
+ if (!prefs.consentGranted) {
+ startActivity(Intent(this, ConsentActivity::class.java))
+ finish()
+ return
+ }
+
lifecycleScope.launch {
rootUtils.getRootShell()
val isRootAccessGiven = checkRootAccess()
@@ -72,7 +78,11 @@ class StartActivity : AppCompatActivity() {
private fun navigate() {
val shortcutNames = Actions.entries.map { it.name }
- val nextIntent = Intent(this, MainActivity::class.java).apply {
+ val nextIntent = if (prefs.consentGranted) {
+ Intent(this, MainActivity::class.java)
+ } else {
+ Intent(this, ConsentActivity::class.java)
+ }.apply {
if (intent.action in shortcutNames) {
putExtra(MainActivity.EXTRA_DESTINATION, intent.action)
putExtras(intent.extras ?: Bundle())
diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/user/UserParamsScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/user/UserParamsScreen.kt
index bd5b529..f575ff5 100644
--- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/user/UserParamsScreen.kt
+++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/user/UserParamsScreen.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -17,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -32,12 +34,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.PreviewScreenSizes
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidvip.sysctlgui.R
@@ -172,11 +176,14 @@ private fun FavoritesScreenContent(
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
- modifier = Modifier.fillMaxSize()
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
itemsIndexed(
items = favoriteParams,
- key = { _, param -> param.name }
+ key = { index, param -> "$index[${param.name}]" }
) { index, param ->
var showParam by remember { mutableStateOf(true) }
val dismissState = rememberSwipeToDismissBoxState()
@@ -211,6 +218,7 @@ private fun FavoritesScreenContent(
Box(
modifier = Modifier
.fillMaxSize()
+ .clip(RoundedCornerShape(24.dp))
.background(color)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterEnd
@@ -225,7 +233,8 @@ private fun FavoritesScreenContent(
) {
ParamFileRow(
modifier = Modifier
- .background(MaterialTheme.colorScheme.background)
+ .clip(RoundedCornerShape(24.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainer)
.fillMaxWidth(),
param = param,
showFavoriteIcon = true,
@@ -238,7 +247,8 @@ private fun FavoritesScreenContent(
}
@Composable
-@PreviewScreenSizes
+@PreviewLightDark
+@Preview(device = "spec:parent=pixel_5,orientation=landscape")
private fun FavoriteScreenContentPreview() {
val params = listOf(
UiKernelParam(
diff --git a/app/src/main/res/drawable/avd_build_off.xml b/app/src/main/res/drawable/avd_build_off.xml
new file mode 100644
index 0000000..1fcf60f
--- /dev/null
+++ b/app/src/main/res/drawable/avd_build_off.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/avd_build_on.xml b/app/src/main/res/drawable/avd_build_on.xml
new file mode 100644
index 0000000..55a9842
--- /dev/null
+++ b/app/src/main/res/drawable/avd_build_on.xml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/avd_favorite_off.xml b/app/src/main/res/drawable/avd_favorite_off.xml
new file mode 100644
index 0000000..4885e9c
--- /dev/null
+++ b/app/src/main/res/drawable/avd_favorite_off.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/avd_favorite_on.xml b/app/src/main/res/drawable/avd_favorite_on.xml
new file mode 100644
index 0000000..39bb3af
--- /dev/null
+++ b/app/src/main/res/drawable/avd_favorite_on.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/avd_home_off.xml b/app/src/main/res/drawable/avd_home_off.xml
new file mode 100644
index 0000000..148679c
--- /dev/null
+++ b/app/src/main/res/drawable/avd_home_off.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/avd_home_on.xml b/app/src/main/res/drawable/avd_home_on.xml
new file mode 100644
index 0000000..7ca4f2f
--- /dev/null
+++ b/app/src/main/res/drawable/avd_home_on.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/avd_settings_off.xml b/app/src/main/res/drawable/avd_settings_off.xml
new file mode 100644
index 0000000..ef6466c
--- /dev/null
+++ b/app/src/main/res/drawable/avd_settings_off.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/avd_settings_on.xml b/app/src/main/res/drawable/avd_settings_on.xml
new file mode 100644
index 0000000..98e604c
--- /dev/null
+++ b/app/src/main/res/drawable/avd_settings_on.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_build.xml b/app/src/main/res/drawable/ic_build.xml
deleted file mode 100644
index 0490ae8..0000000
--- a/app/src/main/res/drawable/ic_build.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_build_filled.xml b/app/src/main/res/drawable/ic_build_filled.xml
deleted file mode 100644
index 3384677..0000000
--- a/app/src/main/res/drawable/ic_build_filled.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml
index 042bd5c..2a2b0b1 100644
--- a/app/src/main/res/drawable/ic_edit.xml
+++ b/app/src/main/res/drawable/ic_edit.xml
@@ -1,5 +1,9 @@
-
-
-
-
+
+
diff --git a/app/src/main/res/drawable/ic_favorite.xml b/app/src/main/res/drawable/ic_favorite.xml
index e71f811..e71748e 100644
--- a/app/src/main/res/drawable/ic_favorite.xml
+++ b/app/src/main/res/drawable/ic_favorite.xml
@@ -5,6 +5,6 @@
android:viewportHeight="960">
+ android:pathData="m480,833.5q-14,0 -28.5,-5t-25.5,-16l-69,-63q-106,-97 -191.5,-192.5t-85.5,-210.5q0,-94 63,-157t157,-63q53,0 100,22.5t80,61.5q33,-39 80,-61.5t100,-22.5q94,0 157,63t63,157q0,115 -85,211t-193,193l-68,62q-11,11 -25.5,16t-28.5,5z" />
diff --git a/app/src/main/res/drawable/ic_favorite_outlined.xml b/app/src/main/res/drawable/ic_favorite_outlined.xml
index 81e986d..a9d1244 100644
--- a/app/src/main/res/drawable/ic_favorite_outlined.xml
+++ b/app/src/main/res/drawable/ic_favorite_outlined.xml
@@ -5,5 +5,5 @@
android:viewportHeight="960">
+ android:pathData="m480,833.5q-14,0 -28.5,-5t-25.5,-16l-69,-63q-106,-97 -191.5,-192.5t-85.5,-210.5q0,-94 63,-157t157,-63q53,0 100,22.5t80,61.5q33,-39 80,-61.5t100,-22.5q94,0 157,63t63,157q0,115 -85,211t-193,193l-68,62q-11,11 -25.5,16t-28.5,5zm-38,-543q-29,-41 -62,-62.5t-80,-21.5q-60,0 -100,40t-40,100q0,52 37,110.5t88.5,113.5q51.5,55 106,103t88.5,79q34,-31 88.5,-79t106,-103q51.5,-55 88.5,-113.5t37,-110.5q0,-60 -40,-100t-100,-40q-47,0 -80,21.5t-62,62.5q-7,10 -17,15t-21,5q-11,0 -21,-5t-17,-15zm38,189z" />
diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml
deleted file mode 100644
index 06d7738..0000000
--- a/app/src/main/res/drawable/ic_home.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_home_filled.xml b/app/src/main/res/drawable/ic_home_filled.xml
deleted file mode 100644
index a1daa5e..0000000
--- a/app/src/main/res/drawable/ic_home_filled.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_restore_settings.xml b/app/src/main/res/drawable/ic_restore_settings.xml
new file mode 100644
index 0000000..3e46be6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_restore_settings.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml
deleted file mode 100644
index 39ed82d..0000000
--- a/app/src/main/res/drawable/ic_settings.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_settings_filled.xml b/app/src/main/res/drawable/ic_settings_filled.xml
deleted file mode 100644
index e93251b..0000000
--- a/app/src/main/res/drawable/ic_settings_filled.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index bfdbbcf..20bc691 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -44,6 +44,7 @@
Iniciar o SysctlGUI
Opções de exportação
Falha: nenhum parâmetro encontrado
+ Tipo de arquivo inválido
A exportação de parâmetros falhou devido a um erro do armazenamento
Importar, exportar ou fazer backup dos parâmetros do kernel
Nevegar
@@ -62,10 +63,10 @@
Parâmetro excluído: %s
Voltar
Ler a documentação para \"%1$s\"
- Diretório: %1$s
- Parâmetro: %1$s
- Ícone de parâmetro
- Navegar para o diretório
+ Diretório: %1$s
+ Parâmetro: %1$s
+ Ícone de parâmetro
+ Navegar para o diretório
Marcado como favorito
Alternar %1$s
Favoritar
@@ -90,7 +91,7 @@
Nenhuma sugestão disponível
Nenhum resultado encontrado para \"%1$s\"
Insira uma consulta para pesquisar parâmetros do kernel
- Ícone de pesquisa
+ Ícone de pesquisa
Limpar pesquisa
Pesquisas recentes
Item do histórico
@@ -105,4 +106,15 @@
Histórico deletado
Parâmetros da inicialização
Sysctl é usado para modificar parâmetros do kernel em tempo de execução. Os parâmetros disponíveis são aqueles listados em /proc/sys e o objetivo principal deste aplicativo é fornecer uma maneira gráfica de editá-los.
+ Consentimento para editar parâmetros
+ Precisamos do seu consentimento para prosseguir. Veja o que o aplicativo pode e não pode fazer após o consentimento ser concedido:\n\t
+ ❌ Executar comandos não relacionados à ferramenta sysctl\n\t
+ ❌ Executar comandos sem permisão ou ação iniciada pelo usuário\n\t
+ ❌ Deixar alterações permanentes\n\t
+ ✅ Executar apenas comandos sysctl para os parâmetros escolhidos\n\t
+ ✅ Reverter todas as alterações
+
+ Eu concordo com o declarado acima
+ Continuar
+ Devido à natureza do kernel, as alterações feitas em tempo de execução podem ser revertidas com uma simples reinicialização do dispositivo. Você encontrará a opção de reversão nas configurações do aplicativo.
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2a5ce33..1f6afc5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -42,6 +42,7 @@
Run Sysctl GUI at boot
Requires root access
Root access required
+ Invalid file type
Parameter export failed due to an IO error
Failed: no parameter found
Export options
@@ -62,10 +63,10 @@
Param deleted: %s
Go back
Read documentation for \"%1$s\"
- Directory: %1$s
- Parameter: %1$s
- Parameter icon
- Navigate to directory
+ Directory: %1$s
+ Parameter: %1$s
+ Parameter icon
+ Navigate to directory
Marked as favorite
Toggle %1$s
Favorite
@@ -90,7 +91,7 @@
No suggestions available
No results found for \"%1$s\"
Enter a query to search for kernel parameters
- Search Icon
+ Search Icon
Clear search
Recent searches
History item
@@ -105,4 +106,15 @@
History deleted
Startup params
Sysctl is used to modify kernel parameters at runtime. The parameters available are those listed under /proc/sys and this app\'s main purpose is to provide a graphical way to edit them.
+ Edit parameters consent
+ We need your consent before we can proceed. This is what the app can and cannot do, once consent is granted:\n\t
+ ❌ Execute commands unrelated to the sysctl tool\n\t
+ ❌ Execute commands without a user-initiated action or permission\n\t
+ ❌ Leave permanent changes in place\n\t
+ ✅ Execute only sysctl commands for the chosen parameters\n\t
+ ✅ Revert back all changes
+
+ I consent to the above
+ Continue
+ By the nature of the kernel, changes done at runtime can be reverted by a simple device reboot. You can find the revert back option in the app settings.
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index 960ac3c..8914394 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -2,6 +2,6 @@
-
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 09c0244..c894863 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,4 +6,5 @@ plugins {
alias(libs.plugins.jetbrains.kotlin.jvm) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.stability.analyzer) apply false
}
\ No newline at end of file
diff --git a/common/design/build.gradle.kts b/common/design/build.gradle.kts
index ef70949..0abefde 100644
--- a/common/design/build.gradle.kts
+++ b/common/design/build.gradle.kts
@@ -45,6 +45,7 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
api(platform(libs.androidx.compose.bom))
+ api(libs.androidx.compose.animation.graphics)
api(libs.androidx.compose.ui)
api(libs.androidx.compose.ui.graphics)
api(libs.androidx.compose.ui.tooling.preview)
diff --git a/common/design/src/androidTest/java/com/androidvip/sysctlgui/design/ExampleInstrumentedTest.kt b/common/design/src/androidTest/java/com/androidvip/sysctlgui/design/ExampleInstrumentedTest.kt
deleted file mode 100644
index d37915e..0000000
--- a/common/design/src/androidTest/java/com/androidvip/sysctlgui/design/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.androidvip.sysctlgui.design
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.androidvip.sysctlgui.design.test", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/common/design/src/main/java/com/androidvip/sysctlgui/design/theme/Color.kt b/common/design/src/main/java/com/androidvip/sysctlgui/design/theme/Color.kt
index e8fc935..282c0d7 100644
--- a/common/design/src/main/java/com/androidvip/sysctlgui/design/theme/Color.kt
+++ b/common/design/src/main/java/com/androidvip/sysctlgui/design/theme/Color.kt
@@ -138,7 +138,7 @@ val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE3E2E9)
val inverseOnSurfaceDark = Color(0xFF2F3036)
val inversePrimaryDark = Color(0xFF4B5C92)
-val surfaceDimDark = Color(0xFF121318)
+val surfaceDimDark = Color(0xFF0D0E13)
val surfaceBrightDark = Color(0xFF38393F)
val surfaceContainerLowestDark = Color(0xFF0D0E13)
val surfaceContainerLowDark = Color(0xFF1A1B21)
@@ -174,7 +174,7 @@ val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFE3E2E9)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF292A2F)
val inversePrimaryDarkMediumContrast = Color(0xFF34467A)
-val surfaceDimDarkMediumContrast = Color(0xFF121318)
+val surfaceDimDarkMediumContrast = Color(0xFF06070C)
val surfaceBrightDarkMediumContrast = Color(0xFF43444A)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF06070C)
val surfaceContainerLowDarkMediumContrast = Color(0xFF1C1D23)
@@ -186,7 +186,7 @@ val primaryDarkHighContrast = Color(0xFFEDEFFF)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFFAFC1FD)
val onPrimaryContainerDarkHighContrast = Color(0xFF000928)
-val secondaryDarkHighContrast = Color(0xFFEDEFFF)
+val secondaryDarkHighContrast = Color(0xFFD7DBF3)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFBDC2D9)
val onSecondaryContainerDarkHighContrast = Color(0xFF060A1B)
@@ -210,7 +210,7 @@ val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFE3E2E9)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF34467A)
-val surfaceDimDarkHighContrast = Color(0xFF121318)
+val surfaceDimDarkHighContrast = Color(0xFF000000)
val surfaceBrightDarkHighContrast = Color(0xFF4F5056)
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
val surfaceContainerLowDarkHighContrast = Color(0xFF1E1F25)
diff --git a/common/design/src/main/java/com/androidvip/sysctlgui/design/theme/Theme.kt b/common/design/src/main/java/com/androidvip/sysctlgui/design/theme/Theme.kt
index 4268a32..e740fc3 100644
--- a/common/design/src/main/java/com/androidvip/sysctlgui/design/theme/Theme.kt
+++ b/common/design/src/main/java/com/androidvip/sysctlgui/design/theme/Theme.kt
@@ -243,7 +243,7 @@ private val highContrastDarkColorScheme = darkColorScheme(
@Composable
fun SysctlGuiTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
- dynamicColor: Boolean = false,
+ dynamicColor: Boolean = true,
contrastLevel: Int = 1,
content: @Composable () -> Unit
) {
diff --git a/common/design/src/main/java/com/androidvip/sysctlgui/design/utils/UiUtils.kt b/common/design/src/main/java/com/androidvip/sysctlgui/design/utils/UiUtils.kt
index 13018c5..78a6307 100644
--- a/common/design/src/main/java/com/androidvip/sysctlgui/design/utils/UiUtils.kt
+++ b/common/design/src/main/java/com/androidvip/sysctlgui/design/utils/UiUtils.kt
@@ -1,12 +1,105 @@
package com.androidvip.sysctlgui.design.utils
import android.content.res.Configuration
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.util.lerp
@Composable
@ReadOnlyComposable
fun isLandscape(): Boolean {
return LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
}
+
+@Composable
+fun StatusBarProtection(
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.background,
+ height: Dp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(),
+) {
+ val gradient = Brush.verticalGradient(listOf(color, Color.Transparent))
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(height)
+ .background(gradient)
+ )
+}
+
+@Composable
+fun AnimatedStrikeThroughIcon(
+ icon: ImageVector,
+ isOff: Boolean,
+ modifier: Modifier = Modifier,
+ tint: Color = LocalContentColor.current,
+) {
+ val painter = rememberVectorPainter(icon)
+ val backgroundColor = MaterialTheme.colorScheme.background
+ val animationProgress by animateFloatAsState(
+ targetValue = if (isOff) 1f else 0f,
+ animationSpec = spring(stiffness = Spring.StiffnessLow),
+ label = "StrikeThruAnimation"
+ )
+
+ Box(
+ modifier = modifier.drawWithCache {
+ onDrawWithContent {
+ with(painter) {
+ draw(size = size, colorFilter = ColorFilter.tint(tint))
+ }
+
+ if (animationProgress > 0f) {
+ val strokeWidth = size.width * 0.08f
+ val start = Offset(
+ x = size.width * 0.2f,
+ y = size.height * 0.2f
+ )
+ val end = Offset(
+ x = lerp(start.x, size.width * 0.8f, animationProgress),
+ y = lerp(start.y, size.height * 0.8f, animationProgress)
+ )
+
+ drawLine(
+ color = backgroundColor,
+ start = start,
+ end = end,
+ strokeWidth = strokeWidth * 3,
+ cap = StrokeCap.Round
+ )
+
+ drawLine(
+ color = tint,
+ start = start,
+ end = end,
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round
+ )
+ }
+ }
+ }
+ )
+}
diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/Prefs.kt b/data/src/main/java/com/androidvip/sysctlgui/data/Prefs.kt
index 02b2ddc..071f005 100644
--- a/data/src/main/java/com/androidvip/sysctlgui/data/Prefs.kt
+++ b/data/src/main/java/com/androidvip/sysctlgui/data/Prefs.kt
@@ -14,5 +14,6 @@ enum class Prefs(val key: String) {
AskedNotificationPermission("asked_notification_permission"),
UseOnlineDocs("use_online_docs"),
ContrastLevel("contrast_level"),
+ ConsentGranted("edit_consent_granted"),
SearchHistory("search_history")
}
diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/models/ParamDocumentationDTO.kt b/data/src/main/java/com/androidvip/sysctlgui/data/models/ParamDocumentationDTO.kt
new file mode 100644
index 0000000..2db7eb9
--- /dev/null
+++ b/data/src/main/java/com/androidvip/sysctlgui/data/models/ParamDocumentationDTO.kt
@@ -0,0 +1,10 @@
+package com.androidvip.sysctlgui.data.models
+
+import com.androidvip.sysctlgui.domain.models.ParamDocumentation
+
+data class ParamDocumentationDTO(
+ override val title: String = "",
+ override val documentationText: String = "",
+ override val documentationHtml: String? = null,
+ override val url: String? = null
+) : ParamDocumentation
diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppPrefsImpl.kt b/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppPrefsImpl.kt
index d21206f..c8519fe 100644
--- a/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppPrefsImpl.kt
+++ b/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppPrefsImpl.kt
@@ -89,6 +89,11 @@ class AppPrefsImpl(private val prefs: SharedPreferences) : AppPrefs {
set(value) {
prefs.edit { putInt(Prefs.ContrastLevel.key, value) }
}
+ override var consentGranted: Boolean
+ get() = prefs.getBoolean(Prefs.ConsentGranted.key, false)
+ set(value) {
+ prefs.edit { putBoolean(Prefs.ConsentGranted.key, value) }
+ }
override val searchHistory: Set
get() = prefs.getStringSet(Prefs.SearchHistory.key, emptySet()) ?: emptySet()
diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppSettingsRepositoryImpl.kt b/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppSettingsRepositoryImpl.kt
index c0d87ca..221e4a2 100644
--- a/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppSettingsRepositoryImpl.kt
+++ b/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppSettingsRepositoryImpl.kt
@@ -12,6 +12,7 @@ import com.androidvip.sysctlgui.domain.models.AppSetting
import com.androidvip.sysctlgui.domain.models.KEY_CONTRIBUTORS
import com.androidvip.sysctlgui.domain.models.KEY_DELETE_HISTORY
import com.androidvip.sysctlgui.domain.models.KEY_MANAGE_PARAMS
+import com.androidvip.sysctlgui.domain.models.KEY_REVERT_CHANGES
import com.androidvip.sysctlgui.domain.models.KEY_SOURCE_CODE
import com.androidvip.sysctlgui.domain.models.KEY_TRANSLATIONS
import com.androidvip.sysctlgui.domain.repository.AppSettingsRepository
@@ -29,8 +30,9 @@ class AppSettingsRepositoryImpl(
) : AppSettingsRepository {
override suspend fun getAppSettings(): List> = withContext(ioContext) {
val isTaskerInstalled = isTaskerInstalled()
- val usingDynamicColors = sharedPreferences.getBoolean(Prefs.DynamicColors.key, false)
val supportsDynamicColors = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ val usingDynamicColors =
+ sharedPreferences.getBoolean(Prefs.DynamicColors.key, supportsDynamicColors)
val currentCommitMode = sharedPreferences.getString(
Prefs.CommitMode.key,
CommitMode.SYSCTL.name.lowercase()
@@ -171,6 +173,14 @@ class AppSettingsRepositoryImpl(
type = SettingItemType.Slider,
values = (0..10).toList(),
),
+ AppSetting(
+ key = KEY_REVERT_CHANGES,
+ value = Unit,
+ category = context.getString(R.string.prefs_category_startup),
+ title = context.getString(R.string.prefs_startup_revert_changes),
+ description = context.getString(R.string.prefs_startup_revert_changes_description),
+ type = SettingItemType.Text
+ ),
/////////// OTHERS ////////////
AppSetting(
diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/source/DocumentationDataSource.kt b/data/src/main/java/com/androidvip/sysctlgui/data/source/DocumentationDataSource.kt
index de5672d..5fe3766 100644
--- a/data/src/main/java/com/androidvip/sysctlgui/data/source/DocumentationDataSource.kt
+++ b/data/src/main/java/com/androidvip/sysctlgui/data/source/DocumentationDataSource.kt
@@ -14,7 +14,7 @@ fun interface DocumentationDataSource {
* Retrieves documentation for a given kernel parameter.
*
* @param param The kernel parameter for which to fetch documentation.
- * @return The documentation if found, null otherwise.
+ * @return The documentation if found, `null` otherwise.
*/
suspend fun getDocumentation(param: KernelParam): ParamDocumentation?
}
diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/source/OfflineDocumentationDataSource.kt b/data/src/main/java/com/androidvip/sysctlgui/data/source/OfflineDocumentationDataSource.kt
index 82d88c4..349c30e 100644
--- a/data/src/main/java/com/androidvip/sysctlgui/data/source/OfflineDocumentationDataSource.kt
+++ b/data/src/main/java/com/androidvip/sysctlgui/data/source/OfflineDocumentationDataSource.kt
@@ -3,6 +3,7 @@ package com.androidvip.sysctlgui.data.source
import android.annotation.SuppressLint
import android.content.Context
import com.androidvip.sysctlgui.data.R
+import com.androidvip.sysctlgui.data.models.ParamDocumentationDTO
import com.androidvip.sysctlgui.domain.models.KernelParam
import com.androidvip.sysctlgui.domain.models.ParamDocumentation
import kotlinx.coroutines.Dispatchers
@@ -54,7 +55,7 @@ class OfflineDocumentationDataSource(
val stringRes = runCatching { context.getString(resId) }.getOrNull()
// Prefer the documented string resource
- if (stringRes != null) return@withContext ParamDocumentation(
+ if (stringRes != null) return@withContext ParamDocumentationDTO(
title = param.name,
documentationText = stringRes
)
@@ -110,7 +111,7 @@ class OfflineDocumentationDataSource(
val documentationText = info.takeIf { it.isNullOrEmpty().not() }
if (documentationText == null) return@withContext null
- return@withContext ParamDocumentation(
+ return@withContext ParamDocumentationDTO(
title = param.name,
documentationText = documentationText
)
diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/source/OnlineDocumentationDataSource.kt b/data/src/main/java/com/androidvip/sysctlgui/data/source/OnlineDocumentationDataSource.kt
index c160563..ab41e19 100644
--- a/data/src/main/java/com/androidvip/sysctlgui/data/source/OnlineDocumentationDataSource.kt
+++ b/data/src/main/java/com/androidvip/sysctlgui/data/source/OnlineDocumentationDataSource.kt
@@ -1,6 +1,7 @@
package com.androidvip.sysctlgui.data.source
import android.util.Log
+import com.androidvip.sysctlgui.data.models.ParamDocumentationDTO
import com.androidvip.sysctlgui.domain.models.KernelParam
import com.androidvip.sysctlgui.domain.models.ParamDocumentation
import io.ktor.client.HttpClient
@@ -50,10 +51,9 @@ class OnlineDocumentationDataSource(
val document = Jsoup.parse(html)
val htmlElementId = param.lastNameSegment.replace('_', '-')
-
if (File(param.path).isDirectory) {
// If we got something out of the request, might as well return at least the URL
- return@withContext ParamDocumentation(
+ return@withContext ParamDocumentationDTO(
title = param.name,
documentationText = "",
documentationHtml = "", // HTML might be huge (directory documentation)
@@ -74,7 +74,7 @@ class OnlineDocumentationDataSource(
elements.removeAt(0)
}
- return@withContext ParamDocumentation(
+ return@withContext ParamDocumentationDTO(
title = param.name,
documentationText = elements.text(),
documentationHtml = elements.html().optimizedDocumentationHtml(),
diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/utils/PresetsFileProcessor.kt b/data/src/main/java/com/androidvip/sysctlgui/data/utils/PresetsFileProcessor.kt
index 8ec44f9..a26442d 100644
--- a/data/src/main/java/com/androidvip/sysctlgui/data/utils/PresetsFileProcessor.kt
+++ b/data/src/main/java/com/androidvip/sysctlgui/data/utils/PresetsFileProcessor.kt
@@ -3,6 +3,8 @@ package com.androidvip.sysctlgui.data.utils
import android.content.ContentResolver
import android.net.Uri
import android.util.Log
+import android.webkit.MimeTypeMap
+import com.androidvip.sysctlgui.domain.exceptions.InvalidFileException
import com.androidvip.sysctlgui.domain.exceptions.NoParameterFoundException
import com.androidvip.sysctlgui.domain.models.KernelParam
import com.androidvip.sysctlgui.utils.isValidSysctlOutputLine
@@ -18,6 +20,7 @@ class PresetsFileProcessor(
suspend fun getKernelParamsFromUri(
uri: Uri
): List = withContext(ioDispatcher) {
+ checkFileType(uri)
contentResolver.openInputStream(uri)?.use { inputStream ->
val lines = inputStream.bufferedReader().readLines()
val params = lines.mapNotNull { line ->
@@ -56,4 +59,19 @@ class PresetsFileProcessor(
}
} ?: throw IOException("Failed to open output stream for URI: $uri")
}
-}
\ No newline at end of file
+
+
+ private suspend fun checkFileType(uri: Uri) = withContext(ioDispatcher) {
+ val mimeType = contentResolver.getType(uri)
+ if (mimeType != null && mimeType.startsWith("text/")) {
+ return@withContext // It's likely a text file, we're good.
+ }
+
+ val fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()).lowercase()
+ val allowedExtensions = listOf("conf", "cfg", "config", "ini", "txt")
+
+ if (fileExtension in allowedExtensions) return@withContext
+
+ throw InvalidFileException("Unsupported file type. MIME type: $mimeType.")
+ }
+}
diff --git a/data/src/main/res/values-pt-rBR/strings.xml b/data/src/main/res/values-pt-rBR/strings.xml
index 73809c7..2de16f5 100644
--- a/data/src/main/res/values-pt-rBR/strings.xml
+++ b/data/src/main/res/values-pt-rBR/strings.xml
@@ -37,4 +37,7 @@
Contribuidores
Traduções
Ajude a traduzir este aplicativo
+ Reverter mudanças
+ Reverter alterações feitas por este aplicativo
+ Devido à natureza do kernel, as alterações feitas em tempo de execução podem ser revertidas com uma simples reinicialização do dispositivo. O aplicativo não tentará mais reaplicar as alterações após um ciclo de inicialização completo.
\ No newline at end of file
diff --git a/data/src/main/res/values/strings.xml b/data/src/main/res/values/strings.xml
index 2b01f67..bd4143c 100644
--- a/data/src/main/res/values/strings.xml
+++ b/data/src/main/res/values/strings.xml
@@ -37,4 +37,7 @@
Contributors
Translations
Help translate this application
+ Revert changes
+ Revert changes made by this app
+ By the nature of the kernel, changes done at runtime can be reverted by a simple device reboot. The app will no longer attempt to reapply changes right after a completed boot cycle.
diff --git a/data/src/test/java/com/androidvip/sysctlgui/data/repository/DocumentationRepositoryImplTest.kt b/data/src/test/java/com/androidvip/sysctlgui/data/repository/DocumentationRepositoryImplTest.kt
index c65dd3e..3770d65 100644
--- a/data/src/test/java/com/androidvip/sysctlgui/data/repository/DocumentationRepositoryImplTest.kt
+++ b/data/src/test/java/com/androidvip/sysctlgui/data/repository/DocumentationRepositoryImplTest.kt
@@ -1,8 +1,8 @@
package com.androidvip.sysctlgui.data.repository
+import com.androidvip.sysctlgui.data.models.ParamDocumentationDTO
import com.androidvip.sysctlgui.data.source.DocumentationDataSource
import com.androidvip.sysctlgui.domain.models.KernelParam
-import com.androidvip.sysctlgui.domain.models.ParamDocumentation
import io.mockk.called
import io.mockk.coEvery
import io.mockk.coVerify
@@ -25,7 +25,7 @@ class DocumentationRepositoryImplTest {
value = "100"
)
- private val expectedDocumentation = ParamDocumentation(
+ private val expectedDocumentation = ParamDocumentationDTO(
title = "Swappiness",
documentationText = "Controls swappiness",
url = "https://docs.kernel.org/admin-guide/sysctl/vm.html#swappiness"
diff --git a/data/src/test/java/com/androidvip/sysctlgui/data/repository/ParamsRepositoryImplTest.kt b/data/src/test/java/com/androidvip/sysctlgui/data/repository/ParamsRepositoryImplTest.kt
new file mode 100644
index 0000000..f41a7f8
--- /dev/null
+++ b/data/src/test/java/com/androidvip/sysctlgui/data/repository/ParamsRepositoryImplTest.kt
@@ -0,0 +1,77 @@
+package com.androidvip.sysctlgui.data.repository
+
+import android.util.Log
+import com.androidvip.sysctlgui.data.utils.RootUtils
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+class ParamsRepositoryImplTest {
+
+ private lateinit var repository: ParamsRepositoryImpl
+ private val rootUtils: RootUtils = mockk()
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ repository = ParamsRepositoryImpl(rootUtils, testDispatcher)
+ mockkStatic(Log::class)
+ every { Log.e(any(), any(), any()) } returns 0
+ }
+
+ @Test
+ fun `given valid sysctl output, when getRuntimeParams is called, then return kernel params`() = runTest(testDispatcher) {
+ // Given
+ val commandOutput = flowOf("net.ipv4.ip_forward = 1", "vm.swappiness = 60")
+ coEvery { rootUtils.executeCommandAndStreamOutput(any()) } returns commandOutput
+
+ // When
+ val params = repository.getRuntimeParams(useBusybox = false, userParams = emptyList()).first()
+
+ // Then
+ assertEquals(2, params.size)
+ assertEquals("net.ipv4.ip_forward", params[0].name)
+ assertEquals("1", params[0].value)
+ assertEquals("vm.swappiness", params[1].name)
+ assertEquals("60", params[1].value)
+ }
+
+ @Test
+ fun `given valid sysctl output, when getRuntimeParam is called, then return kernel param`() = runTest(testDispatcher) {
+ // Given
+ val paramName = "net.ipv4.ip_forward"
+ val commandOutput = flowOf("1")
+ coEvery { rootUtils.executeCommandAndStreamOutput(any()) } returns commandOutput
+
+ // When
+ val param = repository.getRuntimeParam(paramName, useBusybox = false)
+
+ // Then
+ assertNotNull(param)
+ assertEquals(paramName, param?.name)
+ assertEquals("1", param?.value)
+ }
+
+ @Test
+ fun `given empty sysctl output, when getRuntimeParam is called, then return null`() = runTest(testDispatcher) {
+ // Given
+ val paramName = "net.ipv4.ip_forward"
+ coEvery { rootUtils.executeCommandAndStreamOutput(any()) } returns flowOf()
+
+ // When
+ val param = repository.getRuntimeParam(paramName, useBusybox = false)
+
+ // Then
+ assertNull(param)
+ }
+}
diff --git a/data/src/test/java/com/androidvip/sysctlgui/data/repository/PresetRepositoryImplTest.kt b/data/src/test/java/com/androidvip/sysctlgui/data/repository/PresetRepositoryImplTest.kt
new file mode 100644
index 0000000..d505e03
--- /dev/null
+++ b/data/src/test/java/com/androidvip/sysctlgui/data/repository/PresetRepositoryImplTest.kt
@@ -0,0 +1,106 @@
+package com.androidvip.sysctlgui.data.repository
+
+import com.androidvip.sysctlgui.domain.exceptions.EmptyFileException
+import com.androidvip.sysctlgui.domain.exceptions.MalformedLineException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.intellij.lang.annotations.Language
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import java.io.ByteArrayInputStream
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class PresetRepositoryImplTest {
+
+ private lateinit var repository: PresetRepositoryImpl
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ repository = PresetRepositoryImpl(testDispatcher)
+ }
+
+ @Test
+ fun `given valid preset stream, when readPreset is called, then return kernel params`() = runTest(testDispatcher) {
+ // Given
+ val presetContent = "net.ipv4.ip_forward=1\nvm.swappiness=60"
+ val inputStream = ByteArrayInputStream(presetContent.toByteArray())
+
+ // When
+ val params = repository.readPreset(inputStream)
+
+ // Then
+ assertEquals(2, params.size)
+ assertEquals("net.ipv4.ip_forward", params[0].name)
+ assertEquals("1", params[0].value)
+ assertEquals("vm.swappiness", params[1].name)
+ assertEquals("60", params[1].value)
+ }
+
+ @Test(expected = EmptyFileException::class)
+ fun `given empty preset stream, when readPreset is called, then throw EmptyFileException`() = runTest(testDispatcher) {
+ // Given
+ val inputStream = ByteArrayInputStream(byteArrayOf())
+ // When
+ repository.readPreset(inputStream)
+ }
+
+ @Test(expected = MalformedLineException::class)
+ fun `given preset with malformed line, when readPreset is called, then throw MalformedLineException`() = runTest(testDispatcher) {
+ // Given
+ @Language("conf")
+ val presetContent = """
+ net.ipv4.ip_forward=1
+ vm.swappiness
+ """.trimIndent()
+ val inputStream = ByteArrayInputStream(presetContent.toByteArray())
+
+ // When
+ repository.readPreset(inputStream)
+ }
+
+ @Test
+ fun `given preset with comments, when readPreset is called, then ignore comments`() = runTest(testDispatcher) {
+ // Given
+ @Language("conf")
+ val presetContent = """
+ # This is a comment
+ net.ipv4.ip_forward=1
+ ; another comment
+ vm.swappiness=60
+ """.trimIndent()
+ val inputStream = ByteArrayInputStream(presetContent.toByteArray())
+
+ // When
+ val params = repository.readPreset(inputStream)
+
+ // Then
+ assertEquals(2, params.size)
+ assertEquals("net.ipv4.ip_forward", params[0].name)
+ assertEquals("1", params[0].value)
+ assertEquals("vm.swappiness", params[1].name)
+ assertEquals("60", params[1].value)
+ }
+
+ @Test
+ fun `given preset with empty lines, when readPreset is called, then ignore empty lines`() = runTest(testDispatcher) {
+ // Given
+ @Language("conf")
+ val presetContent = """
+
+ net.ipv4.ip_forward=1
+
+ vm.swappiness=60
+
+ """.trimIndent()
+ val inputStream = ByteArrayInputStream(presetContent.toByteArray())
+
+ // When
+ val params = repository.readPreset(inputStream)
+
+ // Then
+ assertEquals(2, params.size)
+ }
+}
diff --git a/data/src/test/java/com/androidvip/sysctlgui/data/repository/UserRepositoryImplTest.kt b/data/src/test/java/com/androidvip/sysctlgui/data/repository/UserRepositoryImplTest.kt
new file mode 100644
index 0000000..4290dcc
--- /dev/null
+++ b/data/src/test/java/com/androidvip/sysctlgui/data/repository/UserRepositoryImplTest.kt
@@ -0,0 +1,51 @@
+
+package com.androidvip.sysctlgui.data.repository
+
+import com.androidvip.sysctlgui.data.db.ParamDao
+import com.androidvip.sysctlgui.data.models.KernelParamDTO
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class UserRepositoryImplTest {
+
+ private val paramDao: ParamDao = mockk(relaxed = true)
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var repository: UserRepositoryImpl
+
+ @Before
+ fun setUp() {
+ repository = UserRepositoryImpl(paramDao, testDispatcher)
+ }
+
+ @Test
+ fun `given a new parameter, when upsertUserParam is called, then DAO is called with id 0`() = runTest(testDispatcher) {
+ // Given
+ val newParam = KernelParamDTO(name = "net.ipv4.ip_forward", value = "1")
+ coEvery { paramDao.getParamByName(newParam.name) } returns null
+
+ // When
+ repository.upsertUserParam(newParam)
+
+ // Then
+ val expectedDto = KernelParamDTO.fromKernelParam(newParam).copy(id = 0)
+ coVerify { paramDao.upsert(expectedDto) }
+ }
+
+ @Test
+ fun `when removeUserParam is called, then DAO delete is called with converted DTO`() = runTest(testDispatcher) {
+ // Given
+ val paramToRemove = KernelParamDTO(id = 1, name = "net.ipv4.ip_forward", value = "1")
+
+ // When
+ repository.removeUserParam(paramToRemove)
+
+ // Then
+ val expectedDto = KernelParamDTO.fromKernelParam(paramToRemove)
+ coVerify { paramDao.delete(expectedDto) }
+ }
+}
diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts
index 63160d8..ff440bc 100644
--- a/domain/build.gradle.kts
+++ b/domain/build.gradle.kts
@@ -31,4 +31,6 @@ dependencies {
implementation(libs.koin)
testImplementation(libs.junit)
+ testImplementation(libs.mockk.android)
+ testImplementation(libs.kotlinx.coroutines.test)
}
diff --git a/domain/src/main/java/com/androidvip/sysctlgui/domain/exceptions/ImportExceptions.kt b/domain/src/main/java/com/androidvip/sysctlgui/domain/exceptions/ImportExceptions.kt
index 1c5ca30..504e1f3 100644
--- a/domain/src/main/java/com/androidvip/sysctlgui/domain/exceptions/ImportExceptions.kt
+++ b/domain/src/main/java/com/androidvip/sysctlgui/domain/exceptions/ImportExceptions.kt
@@ -1,6 +1,9 @@
package com.androidvip.sysctlgui.domain.exceptions
-class InvalidFileExtensionException : Exception()
+/**
+ * Thrown when an invalid file type is trying to be imported.
+ */
+class InvalidFileException(message: String) : Exception(message)
/**
* Thrown when an imported file is empty
*/
diff --git a/domain/src/main/java/com/androidvip/sysctlgui/domain/models/AppSetting.kt b/domain/src/main/java/com/androidvip/sysctlgui/domain/models/AppSetting.kt
index e2a5cdb..a1abb2d 100644
--- a/domain/src/main/java/com/androidvip/sysctlgui/domain/models/AppSetting.kt
+++ b/domain/src/main/java/com/androidvip/sysctlgui/domain/models/AppSetting.kt
@@ -32,6 +32,7 @@ data class AppSetting(
val iconResource: Int? = null
)
+const val KEY_REVERT_CHANGES = "revertChanges"
const val KEY_MANAGE_PARAMS = "manageParams"
const val KEY_DELETE_HISTORY = "deleteHistory"
const val KEY_SOURCE_CODE = "sauce"
diff --git a/domain/src/main/java/com/androidvip/sysctlgui/domain/models/ParamDocumentation.kt b/domain/src/main/java/com/androidvip/sysctlgui/domain/models/ParamDocumentation.kt
index 671d7af..44795bd 100644
--- a/domain/src/main/java/com/androidvip/sysctlgui/domain/models/ParamDocumentation.kt
+++ b/domain/src/main/java/com/androidvip/sysctlgui/domain/models/ParamDocumentation.kt
@@ -8,9 +8,9 @@ package com.androidvip.sysctlgui.domain.models
* @property documentationHtml The HTML formatted documentation, if available.
* @property url The URL to the online documentation, if available.
*/
-data class ParamDocumentation(
- val title: String = "",
- val documentationText: String = "",
- val documentationHtml: String? = null,
- val url: String? = null
-)
+interface ParamDocumentation {
+ val title: String
+ val documentationText: String
+ val documentationHtml: String?
+ val url: String?
+}
diff --git a/domain/src/main/java/com/androidvip/sysctlgui/domain/repository/AppPrefs.kt b/domain/src/main/java/com/androidvip/sysctlgui/domain/repository/AppPrefs.kt
index 16f7f7d..26e5e13 100644
--- a/domain/src/main/java/com/androidvip/sysctlgui/domain/repository/AppPrefs.kt
+++ b/domain/src/main/java/com/androidvip/sysctlgui/domain/repository/AppPrefs.kt
@@ -23,6 +23,7 @@ interface AppPrefs {
var askedForNotificationPermission: Boolean
var useOnlineDocs: Boolean
var contrastLevel: Int
+ var consentGranted: Boolean
val searchHistory: Set
fun addSearchToHistory(query: String)
fun removeSearchFromHistory(query: String)
diff --git a/domain/src/main/java/com/androidvip/sysctlgui/domain/usecase/ApplyParamUseCase.kt b/domain/src/main/java/com/androidvip/sysctlgui/domain/usecase/ApplyParamUseCase.kt
index afa5561..0825def 100644
--- a/domain/src/main/java/com/androidvip/sysctlgui/domain/usecase/ApplyParamUseCase.kt
+++ b/domain/src/main/java/com/androidvip/sysctlgui/domain/usecase/ApplyParamUseCase.kt
@@ -1,11 +1,11 @@
package com.androidvip.sysctlgui.domain.usecase
-import com.androidvip.sysctlgui.domain.models.KernelParam
import com.androidvip.sysctlgui.domain.enums.CommitMode
import com.androidvip.sysctlgui.domain.exceptions.ApplyValueException
import com.androidvip.sysctlgui.domain.exceptions.BlankValueNotAllowedException
import com.androidvip.sysctlgui.domain.exceptions.CommitModeException
import com.androidvip.sysctlgui.domain.exceptions.ShellCommandException
+import com.androidvip.sysctlgui.domain.models.KernelParam
import com.androidvip.sysctlgui.domain.repository.AppPrefs
import com.androidvip.sysctlgui.domain.repository.ParamsRepository
@@ -46,6 +46,8 @@ class ApplyParamUseCase(
}
}
+ } catch (e: CommitModeException) {
+ throw e
} catch (e: ShellCommandException) {
val message = e.cause?.message.orEmpty()
throwApplyValueException(
diff --git a/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/AddUserParamsUseCaseTest.kt b/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/AddUserParamsUseCaseTest.kt
new file mode 100644
index 0000000..99dbfed
--- /dev/null
+++ b/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/AddUserParamsUseCaseTest.kt
@@ -0,0 +1,66 @@
+package com.androidvip.sysctlgui.domain.usecase
+
+import com.androidvip.sysctlgui.domain.exceptions.BlankValueNotAllowedException
+import com.androidvip.sysctlgui.domain.models.KernelParam
+import com.androidvip.sysctlgui.domain.repository.AppPrefs
+import com.androidvip.sysctlgui.domain.repository.UserRepository
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+
+class AddUserParamsUseCaseTest {
+
+ private val repository: UserRepository = mockk(relaxed = true)
+ private val appPrefs: AppPrefs = mockk()
+ private lateinit var useCase: AddUserParamsUseCase
+
+ @Before
+ fun setUp() {
+ useCase = AddUserParamsUseCase(repository, appPrefs)
+ }
+
+ @Test
+ fun `given allow blanks is false and params contain blank value, when invoked, then throw exception`() = runTest {
+ // Given
+ every { appPrefs.allowBlankValues } returns false
+ val params = listOf(KernelParam(name = "net.ipv4.ip_forward", value = "", path = ""))
+
+ // Then
+ assertThrows(BlankValueNotAllowedException::class.java) {
+ // When
+ runBlocking { useCase(params) }
+ }
+ coVerify(exactly = 0) { repository.upsertUserParams(any()) }
+ }
+
+ @Test
+ fun `given allow blanks is false and params do not contain blank value, when invoked, then upsert params`() = runTest {
+ // Given
+ every { appPrefs.allowBlankValues } returns false
+ val params = listOf(KernelParam(name = "net.ipv4.ip_forward", value = "1", path = ""))
+
+ // When
+ useCase(params)
+
+ // Then
+ coVerify(exactly = 1) { repository.upsertUserParams(params) }
+ }
+
+ @Test
+ fun `given allow blanks is true and params contain blank value, when invoked, then upsert params`() = runTest {
+ // Given
+ every { appPrefs.allowBlankValues } returns true
+ val params = listOf(KernelParam(name = "net.ipv4.ip_forward", value = "", path = ""))
+
+ // When
+ useCase(params)
+
+ // Then
+ coVerify(exactly = 1) { repository.upsertUserParams(params) }
+ }
+}
diff --git a/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/ApplyParamUseCaseTest.kt b/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/ApplyParamUseCaseTest.kt
new file mode 100644
index 0000000..f117d2e
--- /dev/null
+++ b/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/ApplyParamUseCaseTest.kt
@@ -0,0 +1,102 @@
+package com.androidvip.sysctlgui.domain.usecase
+
+import com.androidvip.sysctlgui.domain.enums.CommitMode
+import com.androidvip.sysctlgui.domain.exceptions.ApplyValueException
+import com.androidvip.sysctlgui.domain.exceptions.BlankValueNotAllowedException
+import com.androidvip.sysctlgui.domain.exceptions.CommitModeException
+import com.androidvip.sysctlgui.domain.exceptions.ShellCommandException
+import com.androidvip.sysctlgui.domain.models.KernelParam
+import com.androidvip.sysctlgui.domain.repository.AppPrefs
+import com.androidvip.sysctlgui.domain.repository.ParamsRepository
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class ApplyParamUseCaseTest {
+
+ private val repository: ParamsRepository = mockk()
+ private val appPrefs: AppPrefs = mockk()
+ private lateinit var useCase: ApplyParamUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ApplyParamUseCase(repository, appPrefs)
+ }
+
+ @Test(expected = BlankValueNotAllowedException::class)
+ fun `given blank value not allowed, when invoked with blank value, then throw exception`() =
+ runTest {
+ // Given
+ every { appPrefs.allowBlankValues } returns false
+ val param = KernelParam(name = "net.ipv4.ip_forward", value = "", path = "")
+
+ // When
+ useCase(param)
+ }
+
+ @Test(expected = CommitModeException::class)
+ fun `given sysctl mode, when output does not confirm change, then throw exception`() = runTest {
+ // Given
+ every { appPrefs.commitMode } returns CommitMode.SYSCTL.name
+ every { appPrefs.useBusybox } returns false
+ val param = KernelParam(name = "net.ipv4.ip_forward", value = "1", path = "")
+ coEvery { repository.setRuntimeParam(param, CommitMode.SYSCTL, false) } returns ""
+
+ // When
+ useCase(param)
+ }
+
+ @Test(expected = CommitModeException::class)
+ fun `given echo mode, when output is not empty, then throw exception`() = runTest {
+ // Given
+ every { appPrefs.commitMode } returns CommitMode.ECHO.name.lowercase()
+ every { appPrefs.useBusybox } returns false
+ val param = KernelParam(name = "net.ipv4.ip_forward", value = "1", path = "")
+ coEvery { repository.setRuntimeParam(param, CommitMode.ECHO, false) } returns "error"
+
+ // When
+ useCase(param)
+ }
+
+ @Test(expected = ApplyValueException::class)
+ fun `when repository throws ShellCommandException, then throw ApplyValueException`() = runTest {
+ // Given
+ every { appPrefs.commitMode } returns CommitMode.SYSCTL.name.lowercase()
+ every { appPrefs.useBusybox } returns false
+ val param = KernelParam(name = "net.ipv4.ip_forward", value = "1", path = "")
+ coEvery {
+ repository.setRuntimeParam(
+ param,
+ CommitMode.SYSCTL,
+ false
+ )
+ } throws ShellCommandException(
+ "error",
+ Throwable()
+ )
+
+ // When
+ useCase(param)
+ }
+
+ @Test(expected = ApplyValueException::class)
+ fun `when repository throws generic exception, then throw ApplyValueException`() = runTest {
+ // Given
+ every { appPrefs.commitMode } returns CommitMode.SYSCTL.name.lowercase()
+ every { appPrefs.useBusybox } returns false
+ val param = KernelParam(name = "net.ipv4.ip_forward", value = "1", path = "")
+ coEvery {
+ repository.setRuntimeParam(
+ param,
+ CommitMode.SYSCTL,
+ false
+ )
+ } throws Exception("error")
+
+ // When
+ useCase(param)
+ }
+}
diff --git a/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/ExportParamsUseCaseTest.kt b/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/ExportParamsUseCaseTest.kt
new file mode 100644
index 0000000..5a71552
--- /dev/null
+++ b/domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/ExportParamsUseCaseTest.kt
@@ -0,0 +1,49 @@
+package com.androidvip.sysctlgui.domain.usecase
+
+import com.androidvip.sysctlgui.domain.exceptions.NoParameterFoundException
+import com.androidvip.sysctlgui.domain.models.KernelParam
+import com.androidvip.sysctlgui.domain.repository.PresetRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import java.io.FileDescriptor
+
+class ExportParamsUseCaseTest {
+
+ private val repository: PresetRepository = mockk(relaxed = true)
+ private val getUserParamsUseCase: GetUserParamsUseCase = mockk(relaxed = true)
+ private lateinit var useCase: ExportParamsUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ExportParamsUseCase(getUserParamsUseCase, repository)
+ }
+
+ @Test
+ fun `given non-empty user params, when invoked, then export to preset`() = runTest {
+ // Given
+ val fileDescriptor = FileDescriptor()
+ val params = listOf(KernelParam(name = "net.ipv4.ip_forward", value = "1", path = ""))
+ coEvery { getUserParamsUseCase() } returns params
+
+ // When
+ useCase(fileDescriptor)
+
+ // Then
+ coVerify(exactly = 1) { repository.exportToPreset(params, fileDescriptor) }
+ }
+
+ @Test(expected = NoParameterFoundException::class)
+ fun `given empty params, when invoked, then throw NoParameterFoundException`() = runTest {
+ // Given
+ val fileDescriptor = FileDescriptor()
+ val emptyParams = emptyList()
+ coEvery { getUserParamsUseCase() } returns emptyParams
+
+ // When
+ useCase(fileDescriptor)
+ }
+}
diff --git a/fastlane/metadata/android/en-US/changelogs/23.txt b/fastlane/metadata/android/en-US/changelogs/23.txt
new file mode 100644
index 0000000..040736d
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/23.txt
@@ -0,0 +1,6 @@
+- Improved layout and usability in landscape mode.
+- The app now checks for compatible file types when importing settings.
+- The app now uses dynamic colors by default.
+- Added a new option to revert changes.
+- Added a screen to ask for consent before editing kernel parameters.
+- Updated UI with Material You components.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7caea56..ca9c573 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,6 +2,7 @@
activityCompose = "1.11.0"
agp = "8.12.0"
appcompat = "1.7.1"
+composeAnimationGraphics = "1.10.0"
composeBom = "2025.09.01"
composeMaterial3 = "1.5.0-alpha04"
composeMaterial = "1.9.2"
@@ -30,6 +31,7 @@ workRuntimeKtx = "2.10.4"
[libraries]
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-compose-animation-graphics = { group = "androidx.compose.animation", name = "animation-graphics", version.ref = "composeAnimationGraphics" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "composeMaterial" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "composeMaterial3" }
@@ -82,3 +84,4 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.6" }