From 26da4d8e6a7dec37b4ff2129f1f8cbc44bcce63f Mon Sep 17 00:00:00 2001 From: Lennoard Date: Mon, 29 Dec 2025 20:19:51 -0300 Subject: [PATCH 01/11] feat: introduces animated icons in navigation --- .../core/navigation/TopLevelRoute.kt | 8 +- .../sysctlgui/ui/main/MainNavBar.kt | 26 ++--- .../sysctlgui/ui/main/MainNavRail.kt | 27 ++--- .../ui/main/TopLevelRouteProvider.kt | 16 +-- app/src/main/res/drawable/avd_build_off.xml | 104 +++++++++++++++++ app/src/main/res/drawable/avd_build_on.xml | 105 ++++++++++++++++++ .../main/res/drawable/avd_favorite_off.xml | 100 +++++++++++++++++ app/src/main/res/drawable/avd_favorite_on.xml | 100 +++++++++++++++++ app/src/main/res/drawable/avd_home_off.xml | 55 +++++++++ app/src/main/res/drawable/avd_home_on.xml | 53 +++++++++ .../main/res/drawable/avd_settings_off.xml | 45 ++++++++ app/src/main/res/drawable/avd_settings_on.xml | 45 ++++++++ app/src/main/res/drawable/ic_build.xml | 9 -- app/src/main/res/drawable/ic_build_filled.xml | 9 -- app/src/main/res/drawable/ic_home.xml | 9 -- app/src/main/res/drawable/ic_home_filled.xml | 9 -- app/src/main/res/drawable/ic_settings.xml | 9 -- .../main/res/drawable/ic_settings_filled.xml | 9 -- common/design/build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + 20 files changed, 645 insertions(+), 96 deletions(-) create mode 100644 app/src/main/res/drawable/avd_build_off.xml create mode 100644 app/src/main/res/drawable/avd_build_on.xml create mode 100644 app/src/main/res/drawable/avd_favorite_off.xml create mode 100644 app/src/main/res/drawable/avd_favorite_on.xml create mode 100644 app/src/main/res/drawable/avd_home_off.xml create mode 100644 app/src/main/res/drawable/avd_home_on.xml create mode 100644 app/src/main/res/drawable/avd_settings_off.xml create mode 100644 app/src/main/res/drawable/avd_settings_on.xml delete mode 100644 app/src/main/res/drawable/ic_build.xml delete mode 100644 app/src/main/res/drawable/ic_build_filled.xml delete mode 100644 app/src/main/res/drawable/ic_home.xml delete mode 100644 app/src/main/res/drawable/ic_home_filled.xml delete mode 100644 app/src/main/res/drawable/ic_settings.xml delete mode 100644 app/src/main/res/drawable/ic_settings_filled.xml diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/core/navigation/TopLevelRoute.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/core/navigation/TopLevelRoute.kt index ae20822..eeab619 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/core/navigation/TopLevelRoute.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/core/navigation/TopLevelRoute.kt @@ -10,13 +10,13 @@ import androidx.compose.runtime.Immutable * route information to be associated with the top-level destination. * @property name The human-readable name of the top-level destination, used for labels. * @property route The actual [UiRoute] object that defines the navigation destination. - * @property selectedIconRes The icon to display when this top-level route is currently selected. - * @property unselectedIconRes The icon to display when this top-level route is not selected. + * @property selectedAnimatedIconRes The icon to display when this top-level route is currently selected. + * @property unselectedAnimatedIconRes The icon to display when this top-level route is not selected. */ @Immutable data class TopLevelRoute( val name: String, val route: T, - @param:DrawableRes val selectedIconRes: Int, - @param:DrawableRes val unselectedIconRes: Int + @param:DrawableRes val selectedAnimatedIconRes: Int, + @param:DrawableRes val unselectedAnimatedIconRes: Int ) diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainNavBar.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainNavBar.kt index 73d7ea7..55c3f02 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainNavBar.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainNavBar.kt @@ -1,6 +1,8 @@ package com.androidvip.sysctlgui.ui.main -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -9,7 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -35,20 +36,15 @@ internal fun MainNavBar(navController: NavHostController = rememberNavController ?.hierarchy ?.any { it.hasRoute(route.route::class) } == true + val iconRes = if (selected) route.selectedAnimatedIconRes else route.unselectedAnimatedIconRes + val imageVector = AnimatedImageVector.animatedVectorResource(id = iconRes) + val animatedPainter = rememberAnimatedVectorPainter( + animatedImageVector = imageVector, + atEnd = selected + ) + NavigationBarItem( - icon = { - AnimatedContent(targetState = selected) { selectedState -> - val iconRes = if (selectedState) { - route.selectedIconRes - } else { - route.unselectedIconRes - } - Icon( - painter = painterResource(iconRes), - contentDescription = route.name, - ) - } - }, + icon = { Icon(painter = animatedPainter, contentDescription = route.name) }, label = { Text( text = route.name, diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainNavRail.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainNavRail.kt index e46bdfd..c2460f3 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainNavRail.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainNavRail.kt @@ -1,6 +1,8 @@ package com.androidvip.sysctlgui.ui.main -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector import androidx.compose.material3.Icon import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem @@ -9,7 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.navigation.NavDestination.Companion.hasRoute @@ -34,20 +35,16 @@ internal fun MainNavRail(navController: NavHostController = rememberNavControlle ?.hierarchy ?.any { it.hasRoute(route.route::class) } == true + val iconRes = if (selected) route.selectedAnimatedIconRes else route.unselectedAnimatedIconRes + val imageVector = AnimatedImageVector.animatedVectorResource(id = iconRes) + val animatedPainter = rememberAnimatedVectorPainter( + animatedImageVector = imageVector, + atEnd = selected + ) + NavigationRailItem( - icon = { - AnimatedContent(targetState = selected) { selectedState -> - val iconRes = if (selectedState) { - route.selectedIconRes - } else { - route.unselectedIconRes - } - Icon( - painterResource(iconRes), - contentDescription = route.name, - ) - } - }, + icon = { Icon(painter = animatedPainter, contentDescription = route.name) }, + label = { Text( text = route.name, diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/TopLevelRouteProvider.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/TopLevelRouteProvider.kt index d671062..c1ecece 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/TopLevelRouteProvider.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/TopLevelRouteProvider.kt @@ -11,26 +11,26 @@ object TopLevelRouteProvider { TopLevelRoute( name = context.getString(R.string.browse), route = UiRoute.BrowseParams, - selectedIconRes = R.drawable.ic_home_filled, - unselectedIconRes = R.drawable.ic_home + selectedAnimatedIconRes = R.drawable.avd_home_off, + unselectedAnimatedIconRes = R.drawable.avd_home_on ), TopLevelRoute( name = context.getString(R.string.presets), route = UiRoute.Presets, - selectedIconRes = R.drawable.ic_build_filled, - unselectedIconRes = R.drawable.ic_build + selectedAnimatedIconRes = R.drawable.avd_build_off, + unselectedAnimatedIconRes = R.drawable.avd_build_on ), TopLevelRoute( name = context.getString(R.string.favorites), route = UiRoute.Favorites, - selectedIconRes = R.drawable.ic_favorite, - unselectedIconRes = R.drawable.ic_favorite_outlined + selectedAnimatedIconRes = R.drawable.avd_favorite_off, + unselectedAnimatedIconRes = R.drawable.avd_favorite_on ), TopLevelRoute( name = context.getString(R.string.settings), route = UiRoute.Settings, - selectedIconRes = R.drawable.ic_settings_filled, - unselectedIconRes = R.drawable.ic_settings + selectedAnimatedIconRes = R.drawable.avd_settings_off, + unselectedAnimatedIconRes = R.drawable.avd_settings_on ) ) } 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_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_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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7caea56..9bd1e9c 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" } From 88632782105c051111c5d71ce0b22f1de3f31ef1 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Thu, 1 Jan 2026 17:26:56 -0300 Subject: [PATCH 02/11] refactor: improves compose stability and performance - Adds the Compose Stability Analyzer plugin to identify and fix unstable classes. - Marks UI model classes like `@Immutable` or `@Stable`. --- app/build.gradle.kts | 1 + .../androidvip/sysctlgui/models/SearchHint.kt | 3 + .../sysctlgui/models/UiKernelParam.kt | 4 +- .../sysctlgui/models/UiParamDocumentation.kt | 19 +++++++ .../ui/params/DocumentationBottomSheet.kt | 12 ++-- .../ui/params/browse/ParamBrowseScreen.kt | 10 ++-- .../ui/params/browse/ParamBrowseState.kt | 8 ++- .../ui/params/browse/ParamBrowseViewModel.kt | 3 +- .../params/edit/EditParamLandscapeContent.kt | 56 +++++++------------ .../ui/params/edit/EditParamScreen.kt | 27 ++++----- .../ui/params/edit/EditParamViewModel.kt | 11 +++- .../ui/params/edit/EditParamViewState.kt | 6 +- .../sysctlgui/ui/search/SearchScreen.kt | 2 +- .../sysctlgui/ui/search/SearchViewState.kt | 2 + build.gradle.kts | 1 + .../data/models/ParamDocumentationDTO.kt | 10 ++++ .../data/source/DocumentationDataSource.kt | 2 +- .../source/OfflineDocumentationDataSource.kt | 5 +- .../source/OnlineDocumentationDataSource.kt | 6 +- .../sysctlgui/data/utils/RootUtils.kt | 3 +- .../DocumentationRepositoryImplTest.kt | 4 +- .../domain/models/ParamDocumentation.kt | 12 ++-- gradle/libs.versions.toml | 1 + 23 files changed, 119 insertions(+), 89 deletions(-) create mode 100644 app/src/main/kotlin/com/androidvip/sysctlgui/models/UiParamDocumentation.kt create mode 100644 data/src/main/java/com/androidvip/sysctlgui/data/models/ParamDocumentationDTO.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 347ff7c..85944a5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.jetbrains.kotlin.serialization) + alias(libs.plugins.stability.analyzer) id("kotlin-parcelize") } diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/models/SearchHint.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/models/SearchHint.kt index 881e305..4bc9f0f 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/models/SearchHint.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/models/SearchHint.kt @@ -1,5 +1,7 @@ package com.androidvip.sysctlgui.models +import androidx.compose.runtime.Immutable + /** * Represents a search hint displayed to the user. * @@ -9,6 +11,7 @@ package com.androidvip.sysctlgui.models * @property hint The text of the search hint. * @property isFromHistory A boolean flag indicating whether the hint is from the user's search history */ +@Immutable data class SearchHint( val hint: String, val isFromHistory: Boolean = false diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/models/UiKernelParam.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/models/UiKernelParam.kt index 6290784..059e54d 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/models/UiKernelParam.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/models/UiKernelParam.kt @@ -2,7 +2,7 @@ package com.androidvip.sysctlgui.models import android.os.Build import android.os.Parcelable -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable import com.androidvip.sysctlgui.domain.models.KernelParam import com.androidvip.sysctlgui.utils.Consts import kotlinx.parcelize.IgnoredOnParcel @@ -14,7 +14,7 @@ import kotlin.io.path.isDirectory /** * Represents a kernel parameter with additional UI-specific properties. */ -@Stable +@Immutable @Parcelize data class UiKernelParam( override val name: String = "", diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/models/UiParamDocumentation.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/models/UiParamDocumentation.kt new file mode 100644 index 0000000..d2e3601 --- /dev/null +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/models/UiParamDocumentation.kt @@ -0,0 +1,19 @@ +package com.androidvip.sysctlgui.models + +import androidx.compose.runtime.Immutable +import com.androidvip.sysctlgui.domain.models.ParamDocumentation + +@Immutable +data class UiParamDocumentation( + override val title: String = "", + override val documentationText: String = "", + override val documentationHtml: String? = null, + override val url: String? = null +) : ParamDocumentation + +fun ParamDocumentation.toUiParamDocumentation() = UiParamDocumentation( + title = this.title, + documentationText = this.documentationText, + documentationHtml = this.documentationHtml, + url = this.url +) diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/DocumentationBottomSheet.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/DocumentationBottomSheet.kt index eb7b3d2..de29505 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/DocumentationBottomSheet.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/DocumentationBottomSheet.kt @@ -26,20 +26,18 @@ import androidx.compose.ui.text.fromHtml import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import androidx.core.text.HtmlCompat import com.androidvip.sysctlgui.R import com.androidvip.sysctlgui.design.theme.SysctlGuiTheme -import com.androidvip.sysctlgui.domain.models.ParamDocumentation +import com.androidvip.sysctlgui.models.UiParamDocumentation import com.androidvip.sysctlgui.utils.browse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.intellij.lang.annotations.Language -import kotlin.text.append @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun DocumentationBottomSheet( - documentation: ParamDocumentation, + documentation: UiParamDocumentation, sheetState: SheetState ) { val coroutineScope = rememberCoroutineScope() @@ -57,7 +55,7 @@ internal fun DocumentationBottomSheet( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DocumentationBottomSheetContent( - documentation: ParamDocumentation, + documentation: UiParamDocumentation, sheetState: SheetState, coroutineScope: CoroutineScope = rememberCoroutineScope(), ) { @@ -98,7 +96,7 @@ private fun DocumentationBottomSheetContent( if (documentation.url != null) { TextButton( onClick = { - context.browse(documentation.url.orEmpty()) + context.browse(documentation.url) coroutineScope.launch { sheetState.hide() } }, modifier = Modifier @@ -137,7 +135,7 @@ private fun DocumentationBottomSheetPreview() { """.trimIndent() - val documentation = ParamDocumentation( + val documentation = UiParamDocumentation( title = "/proc/sys/fs", url = "https://docs.kernel.org/admin-guide/sysctl/fs.html", documentationText = """ diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseScreen.kt index 049816a..582d762 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseScreen.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseScreen.kt @@ -61,8 +61,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.models.KernelParam -import com.androidvip.sysctlgui.domain.models.ParamDocumentation import com.androidvip.sysctlgui.models.UiKernelParam +import com.androidvip.sysctlgui.models.UiParamDocumentation import com.androidvip.sysctlgui.ui.main.MainViewEvent import com.androidvip.sysctlgui.ui.main.MainViewModel import com.androidvip.sysctlgui.ui.main.MainViewState @@ -84,7 +84,7 @@ fun ParamBrowseScreen( viewModel: ParamBrowseViewModel = koinViewModel(), onParamSelected: (KernelParam) -> Unit ) { - var documentation by remember { mutableStateOf(null) } + var documentation by remember { mutableStateOf(null) } val documentationSheetState = rememberModalBottomSheetState() val context = LocalContext.current val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -148,9 +148,9 @@ fun ParamBrowseScreen( private fun ParamBrowseScreenContent( params: List, currentPath: String, - documentation: ParamDocumentation?, + documentation: UiParamDocumentation?, onParamClicked: (UiKernelParam) -> Unit, - onDocumentationClicked: (ParamDocumentation) -> Unit, + onDocumentationClicked: (UiParamDocumentation) -> Unit, backEnabled: Boolean = false, onBackPressed: () -> Unit, isRefreshing: Boolean, @@ -341,7 +341,7 @@ internal fun ParamBrowseScreenContentPreview() { ParamBrowseScreenContent( params = params, currentPath = currentPath, - documentation = ParamDocumentation( + documentation = UiParamDocumentation( title = currentPath, documentationText = "Documentation for $currentPath", url = null diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseState.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseState.kt index ec5dd8d..786c502 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseState.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseState.kt @@ -1,14 +1,16 @@ package com.androidvip.sysctlgui.ui.params.browse -import com.androidvip.sysctlgui.domain.models.ParamDocumentation +import androidx.compose.runtime.Immutable import com.androidvip.sysctlgui.models.UiKernelParam +import com.androidvip.sysctlgui.models.UiParamDocumentation +@Immutable data class ParamBrowseState( val loading: Boolean = false, val params: List = emptyList(), val currentPath: String = "", val backEnabled: Boolean = false, - val documentation: ParamDocumentation? = null + val documentation: UiParamDocumentation? = null ) sealed interface ParamBrowseViewEffect { @@ -19,7 +21,7 @@ sealed interface ParamBrowseViewEffect { sealed interface ParamBrowseViewEvent { data class ParamClicked(val param: UiKernelParam) : ParamBrowseViewEvent - data class DocumentationClicked(val docs: ParamDocumentation) : ParamBrowseViewEvent + data class DocumentationClicked(val docs: UiParamDocumentation) : ParamBrowseViewEvent object BackRequested : ParamBrowseViewEvent object RefreshRequested : ParamBrowseViewEvent } diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseViewModel.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseViewModel.kt index 30109b2..1fdf9a5 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseViewModel.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamBrowseViewModel.kt @@ -8,6 +8,7 @@ import com.androidvip.sysctlgui.domain.usecase.GetParamsFromFilesUseCase import com.androidvip.sysctlgui.domain.usecase.GetUserParamsUseCase import com.androidvip.sysctlgui.helpers.UiKernelParamMapper import com.androidvip.sysctlgui.models.UiKernelParam +import com.androidvip.sysctlgui.models.toUiParamDocumentation import com.androidvip.sysctlgui.utils.BaseViewModel import com.androidvip.sysctlgui.utils.Consts import com.topjohnwu.superuser.nio.FileSystemManager @@ -88,7 +89,7 @@ class ParamBrowseViewModel( params = newParams, currentPath = parentParam.path, backEnabled = parentParam.path != Consts.PROC_SYS, - documentation = directoryDocumentation, + documentation = directoryDocumentation?.toUiParamDocumentation(), loading = false ) } 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..68c8551 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 @@ -40,8 +40,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,20 +64,18 @@ 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 { @@ -87,6 +85,7 @@ internal fun EditParamLandscapeContent( .background(MaterialTheme.colorScheme.background) .verticalScroll(rememberScrollState()) ) { + val toastCopyMessage = stringResource(R.string.long_press_to_copy) Text( text = param.lastNameSegment, style = MaterialTheme.typography.displayMedium, @@ -98,7 +97,7 @@ internal fun EditParamLandscapeContent( onClick = { Toast.makeText( context, - context.getString(R.string.long_press_to_copy), + toastCopyMessage, Toast.LENGTH_SHORT ).show() }, @@ -112,10 +111,8 @@ internal fun EditParamLandscapeContent( Row( modifier = Modifier.padding( - horizontal = 16.dp, - vertical = if (param.isTaskerParam) 0.dp else 24.dp - ), - verticalAlignment = Alignment.CenterVertically + horizontal = 16.dp, vertical = if (param.isTaskerParam) 0.dp else 24.dp + ), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( @@ -138,22 +135,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) ) } @@ -169,8 +162,7 @@ internal fun EditParamLandscapeContent( contentDescription = stringResource(R.string.tasker_list), tint = MaterialTheme.colorScheme.tertiary ) - } - ) + }) } } @@ -207,8 +199,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 +211,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 +232,7 @@ private fun EditParamContentPreview() { ), taskerAvailable = true, keyboardType = KeyboardType.Number, - documentation = ParamDocumentation( + documentation = UiParamDocumentation( title = "vm.enable_soft_offline", documentationText = "", documentationHtml = htmlDocs, @@ -253,15 +242,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..128f7fc 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 @@ -73,8 +73,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 @@ -228,21 +228,19 @@ 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( @@ -256,6 +254,7 @@ private fun EditParamContent( .fillMaxWidth() .background(MaterialTheme.colorScheme.background) ) { + val toastCopyMessage = stringResource(R.string.long_press_to_copy) Text( text = param.lastNameSegment, style = MaterialTheme.typography.displayLarge, @@ -265,11 +264,7 @@ private fun EditParamContent( 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 ) @@ -486,7 +481,7 @@ internal fun EditableParamValue( @Composable internal fun ParamDocs( modifier: Modifier = Modifier, - documentation: ParamDocumentation?, + documentation: UiParamDocumentation?, onReadMorePressed: () -> Unit, ) { HorizontalDivider() @@ -540,13 +535,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 +622,7 @@ private fun EditParamContentPreview() { ), taskerAvailable = true, keyboardType = KeyboardType.Number, - documentation = ParamDocumentation( + documentation = UiParamDocumentation( title = "vm.enable_soft_offline", documentationText = "", documentationHtml = htmlDocs, 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/search/SearchScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/search/SearchScreen.kt index 86b15a4..4c62ecb 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 @@ -395,7 +395,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/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/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/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/RootUtils.kt b/data/src/main/java/com/androidvip/sysctlgui/data/utils/RootUtils.kt index 93bcfb1..f5ecab9 100644 --- a/data/src/main/java/com/androidvip/sysctlgui/data/utils/RootUtils.kt +++ b/data/src/main/java/com/androidvip/sysctlgui/data/utils/RootUtils.kt @@ -17,7 +17,8 @@ class RootUtils(private val shellDispatcher: CoroutineDispatcher = Dispatchers.D } suspend fun isRootAvailable(): Boolean = withContext(shellDispatcher) { - Shell.isAppGrantedRoot() == true + //Shell.isAppGrantedRoot() == true + true } suspend fun isBusyboxAvailable(): Boolean = withContext(shellDispatcher) { 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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9bd1e9c..ca9c573 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,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" } From 1dbbece2f1c10a3acc6d333948b503fdd61ad8e3 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Thu, 1 Jan 2026 18:50:45 -0300 Subject: [PATCH 03/11] feat: add unit tests for repositories and use cases --- .../sysctlgui/ui/main/MainScreen.kt | 10 ++ .../sysctlgui/ui/main/MainViewState.kt | 1 + .../sysctlgui/data/utils/RootUtils.kt | 3 +- .../repository/ParamsRepositoryImplTest.kt | 77 +++++++++++++ .../repository/PresetRepositoryImplTest.kt | 106 ++++++++++++++++++ .../data/repository/UserRepositoryImplTest.kt | 51 +++++++++ domain/build.gradle.kts | 2 + .../domain/usecase/ApplyParamUseCase.kt | 4 +- .../usecase/AddUserParamsUseCaseTest.kt | 66 +++++++++++ .../domain/usecase/ApplyParamUseCaseTest.kt | 102 +++++++++++++++++ .../domain/usecase/ExportParamsUseCaseTest.kt | 49 ++++++++ 11 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 data/src/test/java/com/androidvip/sysctlgui/data/repository/ParamsRepositoryImplTest.kt create mode 100644 data/src/test/java/com/androidvip/sysctlgui/data/repository/PresetRepositoryImplTest.kt create mode 100644 data/src/test/java/com/androidvip/sysctlgui/data/repository/UserRepositoryImplTest.kt create mode 100644 domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/AddUserParamsUseCaseTest.kt create mode 100644 domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/ApplyParamUseCaseTest.kt create mode 100644 domain/src/test/java/com/androidvip/sysctlgui/domain/usecase/ExportParamsUseCaseTest.kt diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt index 613c15e..5cdb510 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt @@ -19,11 +19,14 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -60,6 +63,7 @@ fun MainScreen( MainScreenContent(state, navController, snackbarHostState, startDestination) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MainScreenContent( state: MainViewState, @@ -69,8 +73,14 @@ private fun MainScreenContent( ) { val onBackPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current val isLandscape = isLandscape() + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) Scaffold( + modifier = if (state.useTopBarScrollBehavior) { + Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + Modifier + }, topBar = { AnimatedVisibility( visible = state.showTopBar, diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt index bc6443c..2f2f1ff 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt @@ -7,6 +7,7 @@ data class MainViewState( val topBarTitle: String = "Sysctl GUI", val showTopBar: Boolean = true, val showNavBar: Boolean = true, + val useTopBarScrollBehavior: Boolean = true, val showBackButton: Boolean = false, val showSearchAction: Boolean = true ) diff --git a/data/src/main/java/com/androidvip/sysctlgui/data/utils/RootUtils.kt b/data/src/main/java/com/androidvip/sysctlgui/data/utils/RootUtils.kt index f5ecab9..93bcfb1 100644 --- a/data/src/main/java/com/androidvip/sysctlgui/data/utils/RootUtils.kt +++ b/data/src/main/java/com/androidvip/sysctlgui/data/utils/RootUtils.kt @@ -17,8 +17,7 @@ class RootUtils(private val shellDispatcher: CoroutineDispatcher = Dispatchers.D } suspend fun isRootAvailable(): Boolean = withContext(shellDispatcher) { - //Shell.isAppGrantedRoot() == true - true + Shell.isAppGrantedRoot() == true } suspend fun isBusyboxAvailable(): Boolean = withContext(shellDispatcher) { 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/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) + } +} From 6c328e19928954e954e1e75f4f37330de62120c1 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Thu, 5 Feb 2026 22:43:54 -0300 Subject: [PATCH 04/11] refactor: improve UI/UX with Material You components --- .../sysctlgui/ui/components/ErrorContainer.kt | 6 +- .../sysctlgui/ui/main/MainActivity.kt | 2 +- .../sysctlgui/ui/main/MainScreen.kt | 11 +- .../sysctlgui/ui/main/MainViewState.kt | 3 +- .../ui/params/edit/ActionToggleButton.kt | 79 ++++++-- .../params/edit/EditParamLandscapeContent.kt | 33 ++-- .../ui/params/edit/EditParamScreen.kt | 54 +++--- .../ui/presets/ImportPresetScreen.kt | 7 +- .../sysctlgui/ui/settings/SettingsScreen.kt | 170 +++++++++++------- .../ui/settings/components/HeaderComponent.kt | 3 - .../components/SliderSettingComponent.kt | 15 +- .../components/SwitchSettingComponent.kt | 14 +- .../components/TextSettingComponent.kt | 11 +- .../sysctlgui/ui/user/UserParamsScreen.kt | 20 ++- app/src/main/res/drawable/ic_edit.xml | 12 +- app/src/main/res/drawable/ic_favorite.xml | 2 +- .../res/drawable/ic_favorite_outlined.xml | 2 +- .../sysctlgui/design/theme/Color.kt | 8 +- 18 files changed, 272 insertions(+), 180 deletions(-) diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/components/ErrorContainer.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/components/ErrorContainer.kt index 6009ced..ece21aa 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/components/ErrorContainer.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/components/ErrorContainer.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -53,6 +54,7 @@ internal fun ErrorContainer(message: String, onAnimationEnd: () -> Unit) { Card( modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer @@ -75,8 +77,8 @@ internal fun ErrorContainer(message: String, onAnimationEnd: () -> Unit) { ) { Icon( modifier = Modifier.size(40.dp), - painter = painterResource(R.drawable.ic_close), - contentDescription = null, + painter = painterResource(R.drawable.ic_warning), + contentDescription = stringResource(R.string.error), tint = MaterialTheme.colorScheme.onErrorContainer ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainActivity.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainActivity.kt index 01a668d..a9fbc9b 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainActivity.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainActivity.kt @@ -29,8 +29,8 @@ class MainActivity : ComponentActivity() { registerForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> } override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) updateEdgeToEdgeConfiguration(prefs.forceDark) + super.onCreate(savedInstanceState) setContent { val themeState by mainViewModel.themeState.collectAsStateWithLifecycle() diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt index 5cdb510..f1105fc 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt @@ -13,20 +13,18 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -73,14 +71,9 @@ private fun MainScreenContent( ) { val onBackPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current val isLandscape = isLandscape() - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) Scaffold( - modifier = if (state.useTopBarScrollBehavior) { - Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - } else { - Modifier - }, + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { AnimatedVisibility( visible = state.showTopBar, diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt index 2f2f1ff..8739453 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt @@ -1,13 +1,14 @@ package com.androidvip.sysctlgui.ui.main import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Immutable import com.androidvip.sysctlgui.data.repository.CONTRAST_LEVEL_NORMAL +@Immutable data class MainViewState( val topBarTitle: String = "Sysctl GUI", val showTopBar: Boolean = true, val showNavBar: Boolean = true, - val useTopBarScrollBehavior: Boolean = true, val showBackButton: Boolean = false, val showSearchAction: Boolean = true ) diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/ActionToggleButton.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/ActionToggleButton.kt index a8819e4..5c15089 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/ActionToggleButton.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/edit/ActionToggleButton.kt @@ -2,6 +2,7 @@ package com.androidvip.sysctlgui.ui.params.edit import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring @@ -11,10 +12,15 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon @@ -24,49 +30,81 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.androidvip.sysctlgui.R import com.androidvip.sysctlgui.design.theme.SysctlGuiTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable internal fun ActionToggleButton( modifier: Modifier = Modifier, + size: Dp = 56.dp, isActive: Boolean, iconOnActive: Painter, iconOnInactive: Painter, contentDescription: String? = null, onToggle: (Boolean) -> Unit, ) { + var internalPressed by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val radius = if (isPressed || isActive) 16.dp else size / 2f + val cornerRadius = animateDpAsState(targetValue = radius) + val coroutineScope = rememberCoroutineScope() val containerColor by animateColorAsState( - targetValue = if (isActive) { - MaterialTheme.colorScheme.secondary - } else { - MaterialTheme.colorScheme.background + targetValue = when { + isPressed && isActive -> MaterialTheme.colorScheme.secondary + isPressed -> MaterialTheme.colorScheme.secondaryContainer + isActive -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.background }, label = "FabContainerColor" ) + val sizeAnimationSpec: AnimationSpec = 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 68c8551..6aad8c0 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,16 @@ 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.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 +29,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 @@ -78,12 +82,21 @@ internal fun EditParamLandscapeContent( Toast.makeText(context, toastCopiedText, Toast.LENGTH_SHORT).show() } - Row { + Row( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .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( @@ -102,18 +115,13 @@ internal fun EditParamLandscapeContent( ).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, @@ -154,7 +162,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,11 +176,13 @@ internal fun EditParamLandscapeContent( 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 @@ -181,13 +190,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 ) 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 128f7fc..bd073e1 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 @@ -247,17 +248,21 @@ private fun EditParamContent( 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, @@ -267,20 +272,13 @@ private fun EditParamContent( 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, @@ -325,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( @@ -339,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 ) @@ -376,8 +376,6 @@ fun ParamValueContent( onBack = { isEditing = false } ) - HorizontalDivider() - Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically @@ -484,8 +482,6 @@ internal fun ParamDocs( documentation: UiParamDocumentation?, onReadMorePressed: () -> Unit, ) { - HorizontalDivider() - Column(modifier = modifier) { Text( text = stringResource(R.string.documentation), @@ -635,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/presets/ImportPresetScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/presets/ImportPresetScreen.kt index 5434991..f22c1e0 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 @@ -75,12 +75,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 +188,7 @@ private fun IncomingPresetsContent( ) { itemsIndexed( items = paramsToImport, - key = { index, item -> item.name } + key = { _, item -> item.name } ) { index, item -> Row( modifier = Modifier @@ -295,7 +296,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/settings/SettingsScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/settings/SettingsScreen.kt index b7199db..71fea58 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,14 +9,18 @@ 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.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -28,6 +32,7 @@ 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.res.painterResource import androidx.compose.ui.res.stringResource @@ -145,81 +150,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() + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - groupedSettings.forEach { (category, categorySettings) -> + groupedSettings.forEach { (category, settingsGroup) -> item(span = { GridItemSpan(columns) }) { - Text( - text = category, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, + 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) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center + ) { Row( modifier = Modifier.padding(all = 16.dp), verticalAlignment = Alignment.CenterVertically, @@ -251,7 +225,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 +235,68 @@ 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 @PreviewLightDark @Preview(device = "spec:parent=pixel_5,orientation=landscape") @@ -308,7 +344,7 @@ internal fun SettingsScreenPreview() { type = SettingItemType.List, values = listOf( CommitMode.SYSCTL.name.lowercase(), - CommitMode.ECHO.name.lowercase(), + CommitMode.ECHO.name.lowercase() ) ), @@ -322,7 +358,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 +366,4 @@ internal fun SettingsScreenPreview() { ) } } -} \ No newline at end of file +} 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/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/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/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) From 6b3984a28a758b661ce0ea9c0cd4163c22980d10 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Wed, 11 Feb 2026 00:17:47 -0300 Subject: [PATCH 05/11] feat: add user consent screen This introduces a mandatory consent screen shown on the first app launch. This screen informs the user about the app's capabilities regarding kernel parameter modifications and requires their agreement before proceeding. Changes include: - Addition of `ConsentActivity` and its layout. - Logic in `StartActivity` to show the consent screen if not yet granted. - New preference to store the consent status. - Updated string resources for the consent screen. --- app/src/main/AndroidManifest.xml | 3 + .../sysctlgui/ui/start/ConsentActivity.kt | 254 ++++++++++++++++++ .../sysctlgui/ui/start/StartActivity.kt | 14 +- .../main/res/drawable/ic_restore_settings.xml | 9 + app/src/main/res/values-pt-rBR/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + .../sysctlgui/design/utils/UiUtils.kt | 93 +++++++ .../com/androidvip/sysctlgui/data/Prefs.kt | 1 + .../sysctlgui/data/repository/AppPrefsImpl.kt | 5 + .../sysctlgui/domain/repository/AppPrefs.kt | 1 + 10 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/androidvip/sysctlgui/ui/start/ConsentActivity.kt create mode 100644 app/src/main/res/drawable/ic_restore_settings.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fd86768..6b192f3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,9 @@ + () + + 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/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/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index bfdbbcf..46c54e7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -105,4 +105,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..83c6fcd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,4 +105,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/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/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/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) From 0ddcfd1ff72b722684e1f4bf4c75e22d7bc669bb Mon Sep 17 00:00:00 2001 From: Lennoard Date: Wed, 11 Feb 2026 23:48:57 -0300 Subject: [PATCH 06/11] feat: add setting to revert changes This introduces a new setting in the "Startup" category that allows users to easily revert any changes made by the app. --- .../sysctlgui/ui/settings/SettingsScreen.kt | 64 +++++++++++++++---- .../ui/settings/SettingsViewModel.kt | 11 ++++ .../ui/settings/model/SettingsViewEvent.kt | 1 + .../repository/AppSettingsRepositoryImpl.kt | 9 +++ data/src/main/res/values-pt-rBR/strings.xml | 3 + data/src/main/res/values/strings.xml | 3 + .../sysctlgui/domain/models/AppSetting.kt | 1 + 7 files changed, 79 insertions(+), 13 deletions(-) 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 71fea58..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 @@ -17,13 +17,15 @@ 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.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 @@ -34,6 +36,7 @@ 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 @@ -60,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 @@ -71,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( @@ -117,7 +122,7 @@ internal fun SettingsScreen( } is SettingsViewEffect.OpenBrowser -> { - context.browse(effect.url) + uriHandler.openUri(effect.url) } is SettingsViewEffect.Navigate -> { @@ -127,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)) }, @@ -140,6 +149,10 @@ internal fun SettingsScreen( viewModel.onEvent(SettingsViewEvent.SettingValueChanged(appSetting, newValue)) } ) + + if (showRevertChangesDialog) { + RevertChangesDialog(onDismissRequest = { showRevertChangesDialog = false }) + } } @Composable @@ -152,15 +165,15 @@ private fun SettingsScreenContent( val columns = if (isLandscape()) 2 else 1 val containerColor = MaterialTheme.colorScheme.surfaceContainer - LazyVerticalGrid( - columns = GridCells.Fixed(columns), + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(columns), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalItemSpacing = 16.dp, horizontalArrangement = Arrangement.spacedBy(16.dp) ) { groupedSettings.forEach { (category, settingsGroup) -> - item(span = { GridItemSpan(columns) }) { + item { Column( modifier = Modifier .fillMaxWidth() @@ -187,7 +200,7 @@ private fun SettingsScreenContent( } } - item(span = { GridItemSpan(columns) }) { + item { Box( modifier = Modifier .clip(RoundedCornerShape(24.dp)) @@ -216,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, @@ -297,6 +310,31 @@ private fun SettingsGroupItem( } } + +@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") 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/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/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..7d86b94 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 @@ -171,6 +172,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/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/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" From 8f6613580560979ddd5603b3f5cacd9ba0d29824 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Wed, 11 Feb 2026 23:51:15 -0300 Subject: [PATCH 07/11] feat: enable dynamic colors by default --- .../kotlin/com/androidvip/sysctlgui/ui/main/MainViewModel.kt | 2 +- .../kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt | 2 +- .../main/java/com/androidvip/sysctlgui/design/theme/Theme.kt | 2 +- .../sysctlgui/data/repository/AppSettingsRepositoryImpl.kt | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewModel.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewModel.kt index 81950f6..88d95c3 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewModel.kt @@ -16,7 +16,7 @@ class MainViewModel( ) : BaseViewModel() { private val themeSettingsFlow: Flow = combine( appPrefs.observeKey(Prefs.ForceDarkTheme.key, false), - appPrefs.observeKey(Prefs.DynamicColors.key, false), + appPrefs.observeKey(Prefs.DynamicColors.key, true), appPrefs.observeKey(Prefs.ContrastLevel.key, CONTRAST_LEVEL_NORMAL) ) { forceDark, dynamicColors, contrastLevel -> ThemeSettings(forceDark, dynamicColors, contrastLevel) diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt index 8739453..61d9a1b 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainViewState.kt @@ -15,7 +15,7 @@ data class MainViewState( data class ThemeSettings( val forceDark: Boolean = false, - val dynamicColors: Boolean = false, + val dynamicColors: Boolean = true, val contrastLevel: Int = CONTRAST_LEVEL_NORMAL ) 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/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppSettingsRepositoryImpl.kt b/data/src/main/java/com/androidvip/sysctlgui/data/repository/AppSettingsRepositoryImpl.kt index 7d86b94..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 @@ -30,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() From 468abc4a04f47ee203a9643b5cdfa87436607284 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Wed, 11 Feb 2026 23:53:06 -0300 Subject: [PATCH 08/11] fix: correct accessibility string typos and update locale config --- .../ui/params/browse/ParamFileRow.kt | 8 +++---- .../sysctlgui/ui/params/browse/ParamRow.kt | 2 +- .../sysctlgui/ui/search/SearchScreen.kt | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 10 ++++---- app/src/main/res/values/strings.xml | 10 ++++---- app/src/main/res/xml/locales_config.xml | 2 +- .../design/ExampleInstrumentedTest.kt | 24 ------------------- 7 files changed, 17 insertions(+), 41 deletions(-) delete mode 100644 common/design/src/androidTest/java/com/androidvip/sysctlgui/design/ExampleInstrumentedTest.kt diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamFileRow.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamFileRow.kt index 08c588a..7d68467 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamFileRow.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamFileRow.kt @@ -41,9 +41,9 @@ fun ParamFileRow( ) { Box(modifier = Modifier.clickable { onParamClicked(param) }) { val rowDescription = if (param.isDirectory) { - stringResource(R.string.acessibility_directory_description_format, param.name) + stringResource(R.string.accessibility_directory_description_format, param.name) } else { - stringResource(R.string.acessibility_param_description_format, param.name) + stringResource(R.string.accessibility_param_description_format, param.name) } Row( modifier = modifier @@ -110,7 +110,7 @@ private fun ParamIcon(param: UiKernelParam) { ) { Icon( painter = painterResource(iconId), - contentDescription = stringResource(R.string.acessibility_param_icon_description), + contentDescription = stringResource(R.string.accessibility_param_icon_description), modifier = Modifier.size(24.dp), tint = iconColor ) @@ -122,7 +122,7 @@ private fun TrailingIcon(param: UiKernelParam, showFavoriteIcon: Boolean) { if (param.isDirectory) { Icon( painter = painterResource(R.drawable.ic_keyboard_arrow_right), - contentDescription = stringResource(R.string.acessibility_davegate_to_directory_description), + contentDescription = stringResource(R.string.accessibility_navigate_to_directory_description), modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamRow.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamRow.kt index 3e04d1a..db753a9 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamRow.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/params/browse/ParamRow.kt @@ -36,7 +36,7 @@ fun ParamRow( showFullName: Boolean = false ) { val rowDescription = stringResource( - R.string.acessibility_param_description_format, + R.string.accessibility_param_description_format, param.name ) val rowState = if (param.isFavorite) stringResource(R.string.marked_as_favorite) else "" 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 4c62ecb..1975fe5 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 @@ -181,7 +181,7 @@ private fun SearchScreenContent( Icon( painter = painterResource(R.drawable.ic_search), contentDescription = stringResource( - R.string.acessibility_search_icon + R.string.accessibility_search_icon ) ) } diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 46c54e7..811e3c2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -62,10 +62,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 +90,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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 83c6fcd..89a2fec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,10 +62,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 +90,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 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/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 From 534e6edd53db7b71a8c298f13b65b627ba6c06a7 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Thu, 12 Feb 2026 22:46:48 -0300 Subject: [PATCH 09/11] feat: add file type validation for import Adds validation to check the file type before importing kernel parameters. The import now only accepts text-based files. --- .../sysctlgui/ui/presets/PresetsScreen.kt | 2 +- .../sysctlgui/ui/presets/PresetsViewModel.kt | 3 +++ app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../data/utils/PresetsFileProcessor.kt | 20 ++++++++++++++++++- .../domain/exceptions/ImportExceptions.kt | 5 ++++- 6 files changed, 29 insertions(+), 3 deletions(-) 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/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 811e3c2..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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89a2fec..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 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/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 */ From 8ed116ca9e67fef94af3b1a2b8cb20c0dc9b8e41 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Thu, 12 Feb 2026 22:48:08 -0300 Subject: [PATCH 10/11] refactor: improve landscape layout handling --- .../sysctlgui/ui/main/MainScreen.kt | 5 ++++- .../params/edit/EditParamLandscapeContent.kt | 2 ++ .../ui/params/edit/EditParamScreen.kt | 6 ++--- .../ui/presets/ImportPresetScreen.kt | 7 +++++- .../sysctlgui/ui/search/SearchScreen.kt | 22 +++++++++++++++---- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt index f1105fc..c60ea75 100644 --- a/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt +++ b/app/src/main/kotlin/com/androidvip/sysctlgui/ui/main/MainScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold @@ -119,7 +120,9 @@ private fun MainScreenContent( } AppNavHost( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .then(if (isLandscape) Modifier.navigationBarsPadding() else Modifier), innerPadding = innerPadding, navController = navController, startDestination = startDestination 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 6aad8c0..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 @@ -11,6 +11,7 @@ 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 @@ -86,6 +87,7 @@ internal fun EditParamLandscapeContent( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) + .displayCutoutPadding() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { 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 bd073e1..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 @@ -96,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 { @@ -154,7 +154,7 @@ fun EditParamScreen( if (isLandscape()) { EditParamLandscapeContent( - state = state.value, + state = state, showError = showError, errorMessage = errorMessage, onValueApply = { @@ -177,7 +177,7 @@ fun EditParamScreen( ) } else { EditParamContent( - state = state.value, + state = state, showError = showError, errorMessage = errorMessage, onValueApply = { 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 f22c1e0..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 @@ -254,6 +256,7 @@ private fun IncomingPresetsContent( modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainer) + .navigationBarsPadding() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) ) { @@ -286,7 +289,9 @@ private fun IncomingPresetsLandscapeContent( onCancelPressed: () -> Unit ) { Row( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .displayCutoutPadding(), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { LazyColumn( 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 1975fe5..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( @@ -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( From 9e300954576dea7756d915a34717b49979636ff1 Mon Sep 17 00:00:00 2001 From: Lennoard Date: Thu, 12 Feb 2026 23:02:56 -0300 Subject: [PATCH 11/11] release: [3.1.0] versionCode 23 Signed-off-by: Lennoard --- app/build.gradle.kts | 6 +++--- fastlane/metadata/android/en-US/changelogs/23.txt | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/23.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85944a5..75ed966 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = AppConfig.appId minSdk = AppConfig.minSdkVersion targetSdk = AppConfig.targetSdkVersion - versionCode = 22 - versionName = "3.0.3" + versionCode = 23 + versionName = "3.1.0" vectorDrawables.useSupportLibrary = true androidResources { localeFilters += listOf("en", "de", "pt-rBR", "tr") @@ -130,4 +130,4 @@ dependencies { implementation(libs.koin) implementation(libs.koin.compose) implementation(libs.bundles.libsu) -} \ No newline at end of file +} 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.