From cbb6339f08c39b1535b41a3cfa74b5d2ba22c0a2 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 18:25:09 +0100 Subject: [PATCH 01/23] chore(ui): Hide the pause action in the service notification for now --- .../services/BroadcastReceiverService.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt index e159020d..1007a786 100644 --- a/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt +++ b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt @@ -25,7 +25,6 @@ import android.util.Log import androidx.core.app.NotificationCompat import dev.lexip.hecate.HecateApplication import dev.lexip.hecate.R -import dev.lexip.hecate.analytics.AnalyticsLogger import dev.lexip.hecate.broadcasts.ScreenOnReceiver import dev.lexip.hecate.data.UserPreferencesRepository import dev.lexip.hecate.util.DarkThemeHandler @@ -148,22 +147,6 @@ class BroadcastReceiverService : Service() { pendingIntent ).build() - // Create action to pause/kill the service. The service will start again on next boot or app open. - val stopIntent = Intent(this, BroadcastReceiverService::class.java).apply { - action = ACTION_PAUSE_SERVICE - } - val pausePendingIntent = PendingIntent.getService( - this, - 0, - stopIntent, - PendingIntent.FLAG_IMMUTABLE - ) - val pauseAction = NotificationCompat.Action.Builder( - 0, - getString(R.string.action_pause_service), - pausePendingIntent - ).build() - // Build notification val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentTitle(getString(R.string.app_name)) @@ -173,7 +156,6 @@ class BroadcastReceiverService : Service() { .setOnlyAlertOnce(true) .setContentIntent(pendingIntent) .addAction(disableAction) - .addAction(pauseAction) .setOngoing(true) From 9d6e56d885f9ca9900e58f65d3d0abedb5befb79 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 18:05:11 +0100 Subject: [PATCH 02/23] fix: Always restart the service onResume when adaptive theme is enabled * Fixes #69 * It may have been paused by the user in the meantime (notification, foreground service menu, etc.) --- .../java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt | 2 ++ app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt index 5af4e377..560808fe 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -61,6 +61,8 @@ class AdaptiveThemeViewModel( private val _uiState = MutableStateFlow(AdaptiveThemeUiState()) val uiState: StateFlow = _uiState.asStateFlow() + fun isAdaptiveThemeEnabled(): Boolean = _uiState.value.adaptiveThemeEnabled + // One-shot UI events private val _uiEvents = MutableSharedFlow( replay = 0, diff --git a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt index 1cbefc6b..9e085fba 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import dev.lexip.hecate.HecateApplication import dev.lexip.hecate.data.UserPreferencesRepository +import dev.lexip.hecate.services.BroadcastReceiverService import dev.lexip.hecate.ui.theme.HecateTheme import dev.lexip.hecate.util.DarkThemeHandler import dev.lexip.hecate.util.InstallSourceChecker @@ -86,6 +87,12 @@ class MainActivity : ComponentActivity() { super.onResume() if (this::adaptiveThemeViewModel.isInitialized) { adaptiveThemeViewModel.startSensorsIfEnabled() + + // Always restart the service (it may have been paused in the meantime) + if (adaptiveThemeViewModel.isAdaptiveThemeEnabled()) { + val intent = android.content.Intent(this, BroadcastReceiverService::class.java) + androidx.core.content.ContextCompat.startForegroundService(this, intent) + } } inAppUpdateManager?.resumeImmediateUpdateIfNeeded() } From 5b8ebf584aacd132c773d5e2eca8988915a24c31 Mon Sep 17 00:00:00 2001 From: xLexip Date: Mon, 8 Dec 2025 11:26:28 +0100 Subject: [PATCH 03/23] chore: Implement flexible update prompts and easy prompt intrusiveness * Flexible update prompts after 1 day * Immediate update prompts after 3 days --- .../dev/lexip/hecate/ui/InAppUpdateManager.kt | 83 ++++++++++++++++++- .../java/dev/lexip/hecate/ui/MainActivity.kt | 9 +- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt index 0afed3ed..616cc6fc 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt @@ -28,8 +28,10 @@ import dev.lexip.hecate.analytics.AnalyticsGate import dev.lexip.hecate.analytics.AnalyticsLogger private const val TAG = "InAppUpdateManager" -private const val DAYS_FOR_IMMEDIATE_UPDATE = 0 +private const val DAYS_FOR_IMMEDIATE_UPDATE = 3 private const val MIN_PRIORITY_FOR_IMMEDIATE = 0 +private const val DAYS_FOR_FLEXIBLE_UPDATE = 1 +private const val MIN_PRIORITY_FOR_FLEXIBLE = 0 class InAppUpdateManager(activity: ComponentActivity) { @@ -121,6 +123,52 @@ class InAppUpdateManager(activity: ComponentActivity) { } } + fun checkForFlexibleUpdate( + onNoUpdate: () -> Unit = {}, + onError: (Throwable) -> Unit = {} + ) { + if (!AnalyticsGate.isPlayStoreInstall()) { + return + } + val launcher = updateLauncher + if (launcher == null) { + Log.w(TAG, "checkForFlexibleUpdate called before launcher was registered") + return + } + val manager = appUpdateManager ?: return + + manager.appUpdateInfo + .addOnSuccessListener { appUpdateInfo -> + val availability = appUpdateInfo.updateAvailability() + val isFlexibleAllowed = appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) + val staleness = appUpdateInfo.clientVersionStalenessDays() ?: -1 + val priority = appUpdateInfo.updatePriority() + + val meetsStaleness = staleness == -1 || staleness >= DAYS_FOR_FLEXIBLE_UPDATE + val meetsPriority = priority >= MIN_PRIORITY_FOR_FLEXIBLE + + if (availability == UpdateAvailability.UPDATE_AVAILABLE && isFlexibleAllowed && meetsStaleness && meetsPriority) { + Log.i(TAG, "Flexible in-app update: starting update flow") + try { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + launcher, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build() + ) + } catch (t: Throwable) { + Log.e(TAG, "Failed to launch flexible in-app update", t) + onError(t) + } + } else { + onNoUpdate() + } + } + .addOnFailureListener { throwable -> + Log.e(TAG, "Failed to retrieve appUpdateInfo for flexible update", throwable) + onError(throwable) + } + } + fun resumeImmediateUpdateIfNeeded() { if (!AnalyticsGate.isPlayStoreInstall()) { return @@ -153,4 +201,37 @@ class InAppUpdateManager(activity: ComponentActivity) { Log.e(TAG, "Failed to check for in-progress immediate update", throwable) } } + + fun resumeFlexibleUpdateIfNeeded() { + if (!AnalyticsGate.isPlayStoreInstall()) { + return + } + val launcher = updateLauncher + if (launcher == null) { + Log.w(TAG, "resumeFlexibleUpdateIfNeeded called before launcher was registered") + return + } + val manager = appUpdateManager ?: return + + manager.appUpdateInfo + .addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS && + appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) + ) { + Log.i(TAG, "Resuming in-progress flexible in-app update") + try { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + launcher, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build() + ) + } catch (t: Throwable) { + Log.e(TAG, "Failed to resume flexible in-app update", t) + } + } + } + .addOnFailureListener { throwable -> + Log.e(TAG, "Failed to check for in-progress flexible update", throwable) + } + } } diff --git a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt index 9e085fba..a86dc7e1 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt @@ -81,20 +81,23 @@ class MainActivity : ComponentActivity() { } inAppUpdateManager?.checkForImmediateUpdate() + inAppUpdateManager?.checkForFlexibleUpdate() } override fun onResume() { super.onResume() + + inAppUpdateManager?.resumeImmediateUpdateIfNeeded() + inAppUpdateManager?.resumeFlexibleUpdateIfNeeded() + + // Always restart the service (it may have been paused in the meantime) if (this::adaptiveThemeViewModel.isInitialized) { adaptiveThemeViewModel.startSensorsIfEnabled() - - // Always restart the service (it may have been paused in the meantime) if (adaptiveThemeViewModel.isAdaptiveThemeEnabled()) { val intent = android.content.Intent(this, BroadcastReceiverService::class.java) androidx.core.content.ContextCompat.startForegroundService(this, intent) } } - inAppUpdateManager?.resumeImmediateUpdateIfNeeded() } override fun onPause() { From 916c0b7182899cc4af02a044034a5f2a0a4c271f Mon Sep 17 00:00:00 2001 From: xLexip Date: Mon, 8 Dec 2025 12:18:01 +0100 Subject: [PATCH 04/23] feat(ui): Show the live lux measurement when the service is enabled #66 * Also change the lux formatter util to not return any decimal places. --- .../preferences/ProgressDetailCard.kt | 27 +++++++++++++++++++ .../java/dev/lexip/hecate/util/FormatUtils.kt | 8 +++--- app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt index 8ff14dfb..a7e109b1 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt @@ -21,11 +21,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import dev.lexip.hecate.R +import dev.lexip.hecate.util.formatLux @Composable fun ProgressDetailCard( @@ -63,6 +69,27 @@ fun ProgressDetailCard( activeIndex = activeIndex, enabled = enabled ) + + // Live lux measurement + if (enabled) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.label_live_measurement), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = "${currentLux.formatLux()} lx", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End + ) + } + } } } } diff --git a/app/src/main/java/dev/lexip/hecate/util/FormatUtils.kt b/app/src/main/java/dev/lexip/hecate/util/FormatUtils.kt index dece9df5..3ac44259 100644 --- a/app/src/main/java/dev/lexip/hecate/util/FormatUtils.kt +++ b/app/src/main/java/dev/lexip/hecate/util/FormatUtils.kt @@ -14,6 +14,7 @@ package dev.lexip.hecate.util import java.text.NumberFormat import java.util.Locale +import kotlin.math.roundToInt /** * Extension to format lux values with locale-aware thousands separators. @@ -25,9 +26,6 @@ fun Int.formatLux(): String { } fun Float.formatLux(): String { - val nf = NumberFormat.getNumberInstance(Locale.getDefault()).apply { - maximumFractionDigits = if (this@formatLux % 1f == 0f) 0 else 1 - minimumFractionDigits = 0 - } - return nf.format(this) + val nf = NumberFormat.getIntegerInstance(Locale.getDefault()) + return nf.format(this.roundToInt()) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4f9a5e66..332f3c12 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -77,4 +77,6 @@ Einrichtung erforderlich %1$s benötigt eine einmalige Einrichtung, um zu funktionieren. Einrichtung starten + + Live-Wert \ 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 ce28f283..8a82b922 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,4 +81,6 @@ Setup Required %1$s requires a one-time setup to function. Start Setup + + Live Value \ No newline at end of file From 36b57e175f34d9c75ede53c2405393669b270256 Mon Sep 17 00:00:00 2001 From: xLexip Date: Mon, 8 Dec 2025 18:20:08 +0100 Subject: [PATCH 05/23] refactor(ui): Outsource ForExpertsSection into own file --- .../hecate/ui/setup/ForExpertsSection.kt | 145 ++++++++++++++++++ .../hecate/ui/setup/PermissionSetupCommon.kt | 112 -------------- 2 files changed, 145 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt new file mode 100644 index 00000000..0abcabf8 --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2025 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.lexip.hecate.R + +@Composable +internal fun ForExpertsSection( + adbCommand: String?, + onCopyAdbCommand: (() -> Unit)? = null, + onShareExpertCommand: (() -> Unit)? = null, +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.permission_wizard_for_experts), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = null + ) + } + } + + if (expanded) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.permission_wizard_manual_command), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.small + ) { + Text( + text = adbCommand ?: "", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onCopyAdbCommand?.invoke() + Toast.makeText( + context, + R.string.permission_wizard_copied, + Toast.LENGTH_SHORT + ).show() + }, + modifier = Modifier.weight(1f) + ) { + Text(text = stringResource(id = R.string.action_copy)) + } + + Button( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onShareExpertCommand?.invoke() + }, + modifier = Modifier.weight(1f) + ) { + Text(text = stringResource(id = R.string.action_share)) + } + } + } + } + } +} + diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt index 561c63b1..45cdd824 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt @@ -12,15 +12,12 @@ package dev.lexip.hecate.ui.setup -import android.widget.Toast import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -32,33 +29,19 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.outlined.Circle -import androidx.compose.material.icons.outlined.ExpandLess -import androidx.compose.material.icons.outlined.ExpandMore -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import dev.lexip.hecate.R @Composable internal fun StatusCard( @@ -142,98 +125,3 @@ internal fun StatusCard( } } } - -@Composable -internal fun ForExpertsSection( - adbCommand: String?, - onCopyAdbCommand: (() -> Unit)? = null, - onShareExpertCommand: (() -> Unit)? = null, -) { - val context = LocalContext.current - val haptic = LocalHapticFeedback.current - var expanded by remember { mutableStateOf(false) } - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.permission_wizard_for_experts), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = { expanded = !expanded }) { - Icon( - imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, - contentDescription = null - ) - } - } - - if (expanded) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_manual_command), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(12.dp)) - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shape = MaterialTheme.shapes.small - ) { - Text( - text = adbCommand ?: "", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - onCopyAdbCommand?.invoke() - Toast.makeText( - context, - R.string.permission_wizard_copied, - Toast.LENGTH_SHORT - ).show() - }, - modifier = Modifier.weight(1f) - ) { - Text(text = stringResource(id = R.string.action_copy)) - } - - Button( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - onShareExpertCommand?.invoke() - }, - modifier = Modifier.weight(1f) - ) { - Text(text = stringResource(id = R.string.action_share)) - } - } - } - } - } -} From 76adf6be7f7f4649fc238b54e8760f9f03cbc54d Mon Sep 17 00:00:00 2001 From: xLexip Date: Mon, 8 Dec 2025 18:26:14 +0100 Subject: [PATCH 06/23] feat(setup): Implement Shizuku support * Implements #63 * A Shizuku option is offered in the setup wizard when Shizuku is installed. --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 13 ++ .../lexip/hecate/analytics/AnalyticsLogger.kt | 6 +- .../lexip/hecate/ui/AdaptiveThemeScreen.kt | 6 + .../lexip/hecate/ui/AdaptiveThemeViewModel.kt | 150 +++++++++++++++- .../preferences/ProgressDetailCard.kt | 2 +- .../hecate/ui/setup/ForExpertsSection.kt | 2 - .../hecate/ui/setup/PermissionSetupHost.kt | 16 +- .../hecate/ui/setup/PermissionSetupSteps.kt | 46 +++-- .../ui/setup/PermissionSetupWizardScreen.kt | 8 + .../hecate/ui/setup/ShizukuOptionCard.kt | 72 ++++++++ .../lexip/hecate/util/InstallSourceChecker.kt | 8 +- .../lexip/hecate/util/shizuku/GrantService.kt | 59 ++++++ .../util/shizuku/ShizukuAvailability.kt | 22 +++ .../hecate/util/shizuku/ShizukuManager.kt | 169 ++++++++++++++++++ app/src/main/res/values-de/strings.xml | 15 +- app/src/main/res/values/strings.xml | 15 +- gradle/libs.versions.toml | 3 + 18 files changed, 581 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/ShizukuOptionCard.kt create mode 100644 app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt create mode 100644 app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuAvailability.kt create mode 100644 app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0e67421..4e4ebd20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -100,6 +100,8 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.app.update.ktx) + implementation(libs.shizuku.api) + implementation(libs.shizuku.provider) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 181296dd..d44083e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,10 @@ xmlns:android="http://schemas.android.com/apk/res/android" tools:ignore="ProtectedPermissions"> + + + + @@ -52,6 +56,15 @@ android:localeConfig="@xml/locales_config" tools:targetApi="33"> + + + = _uiState.asStateFlow() + fun setShizukuInstalled(installed: Boolean) { + if (_uiState.value.isShizukuInstalled == installed) return + _uiState.value = _uiState.value.copy(isShizukuInstalled = installed) + } + fun isAdaptiveThemeEnabled(): Boolean = _uiState.value.adaptiveThemeEnabled // One-shot UI events @@ -135,6 +155,9 @@ class AdaptiveThemeViewModel( // Temporary variable for custom threshold private var customThresholdTemp: Float? = null + // Keep a reference to the registered Shizuku listener for removal in onCleared + private var registeredShizukuListener: Shizuku.OnRequestPermissionResultListener? = null + init { viewModelScope.launch { userPreferencesRepository.userPreferencesFlow.collect { userPreferences -> @@ -152,6 +175,36 @@ class AdaptiveThemeViewModel( } } } + + // Log when Shizuku is present on the device + val hasShizuku = + ShizukuAvailability.isShizukuInstalled(application.applicationContext) + if (hasShizuku) { + AnalyticsLogger.logServiceEnabled( + application.applicationContext, + source = "shizuku_found" + ) + } + + // Create and register the Shizuku permission listener here so it is fully initialized + val listener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult -> + val granted = grantResult == PackageManager.PERMISSION_GRANTED + if (granted && requestCode == REQUEST_CODE_SHIZUKU) { + AnalyticsLogger.logServiceEnabled( + application.applicationContext, + source = "shizuku_permission_granted" + ) + onGrantViaShizukuRequested(application.packageName) + } else if (!granted && requestCode == REQUEST_CODE_SHIZUKU) { + Toast.makeText( + application.applicationContext, + application.getString(R.string.shizuku_denied_rationale), + Toast.LENGTH_LONG + ).show() + } + } + registeredShizukuListener = listener + Shizuku.addRequestPermissionResultListener(listener) } private fun startLightSensorListening() { @@ -171,6 +224,7 @@ class AdaptiveThemeViewModel( } override fun onCleared() { + registeredShizukuListener?.let { Shizuku.removeRequestPermissionResultListener(it) } stopLightSensorListening() stopProximityListening() super.onCleared() @@ -346,6 +400,98 @@ class AdaptiveThemeViewModel( val intent = Intent(application.applicationContext, BroadcastReceiverService::class.java) application.applicationContext.stopService(intent) } + + fun onGrantViaShizukuRequested(packageName: String) { + val context = application.applicationContext + + if (!ShizukuManager.isBinderReady()) { + Toast.makeText( + context, + context.getString(R.string.shizuku_not_ready), + Toast.LENGTH_LONG + ).show() + openShizukuAppIfInstalled(context) + return + } + + if (!ShizukuManager.hasPermission()) { + Toast.makeText( + context, + context.getString(R.string.shizuku_request_permission), + Toast.LENGTH_LONG + ).show() + ShizukuManager.requestPermission(context) + return + } + + viewModelScope.launch(ioDispatcher) { + val result = ShizukuManager.executeGrantViaShizuku(packageName) + withContext(mainDispatcher) { + when (result) { + is ShizukuManager.GrantResult.Success -> { + // Setup using Shizuku complete + AnalyticsLogger.logSetupFinished( + application.applicationContext, + source = "shizuku_setup_complete" + ) + } + + is ShizukuManager.GrantResult.ServiceNotRunning -> { + Toast.makeText( + context, + context.getString(R.string.shizuku_not_ready), + Toast.LENGTH_LONG + ).show() + openShizukuAppIfInstalled(context) + } + + is ShizukuManager.GrantResult.NotAuthorized -> { + Toast.makeText( + context, + context.getString(R.string.shizuku_not_ready), + Toast.LENGTH_LONG + ).show() + } + + is ShizukuManager.GrantResult.ShellCommandFailed -> { + Toast.makeText( + context, + context.getString(R.string.shizuku_grant_shell_failed, result.exitCode), + Toast.LENGTH_LONG + ).show() + } + + is ShizukuManager.GrantResult.Unexpected -> { + Toast.makeText( + context, + context.getString(R.string.shizuku_grant_unexpected), + Toast.LENGTH_LONG + ).show() + } + } + } + } + } + + private fun openShizukuAppIfInstalled(context: android.content.Context) { + val pm = context.packageManager + try { + pm.getPackageInfo(SHIZUKU_PACKAGE, 0) + val launchIntent = pm.getLaunchIntentForPackage(SHIZUKU_PACKAGE) + if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(launchIntent) + } + } catch (_: PackageManager.NameNotFoundException) { + // Shizuku not installed + } catch (_: ActivityNotFoundException) { + // No launchable activity + } + } + + companion object { + private const val REQUEST_CODE_SHIZUKU = 1001 + } } class AdaptiveThemeViewModelFactory( diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt index a7e109b1..b73a6e8c 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt @@ -77,7 +77,7 @@ fun ProgressDetailCard( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(id = R.string.label_live_measurement), + text = stringResource(id = R.string.title_live_measurement), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt index 0abcabf8..91905a54 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandLess -import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -142,4 +141,3 @@ internal fun ForExpertsSection( } } } - diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt index b3568e50..4403c022 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.core.content.ContextCompat import dev.lexip.hecate.analytics.AnalyticsLogger import dev.lexip.hecate.ui.AdaptiveThemeViewModel +import dev.lexip.hecate.util.shizuku.ShizukuAvailability @Composable fun PermissionSetupHost( @@ -33,6 +34,8 @@ fun PermissionSetupHost( var isUsbConnected by remember { mutableStateOf(false) } var hasPermission by remember { mutableStateOf(false) } + val isShizukuInstalled = remember { ShizukuAvailability.isShizukuInstalled(context) } + SideEffect { AnalyticsLogger.logSetupStarted(context) } @@ -178,6 +181,11 @@ fun PermissionSetupHost( hasWriteSecureSettings = hasPermission, isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, isUsbDebuggingEnabled = isUsbDebuggingEnabled, + isShizukuInstalled = isShizukuInstalled, + onGrantViaShizuku = { + // Trigger the ViewModel’s Shizuku-based grant flow + viewModel.onGrantViaShizukuRequested(context.packageName) + }, onNext = { haptic.performHapticFeedback(HapticFeedbackType.ContextClick) when (internalUiState.permissionWizardStep) { @@ -193,7 +201,9 @@ fun PermissionSetupHost( PermissionWizardStep.GRANT_PERMISSION -> { if (hasPermission) { - AnalyticsLogger.logSetupFinished(context) + val source = + if (isUsbConnected) "permission_wizard_complete_usb" else "permission_wizard_complete" + AnalyticsLogger.logSetupFinished(context, source = source) viewModel.completePermissionWizardAndEnableService() } else { viewModel.goToNextPermissionWizardStep() @@ -236,7 +246,9 @@ fun PermissionSetupHost( viewModel.recheckWriteSecureSettingsPermission(nowGranted) if (nowGranted) { haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - AnalyticsLogger.logSetupFinished(context) + val source = + if (isUsbConnected) "permission_wizard_check_now_granted_usb" else "permission_wizard_check_now_granted" + AnalyticsLogger.logSetupFinished(context, source = source) viewModel.completePermissionWizardAndEnableService() } } diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt index 52b60e6a..aeffebc8 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt @@ -60,6 +60,8 @@ import dev.lexip.hecate.R internal fun DeveloperModeStep( isDeveloperOptionsEnabled: Boolean, isUsbDebuggingEnabled: Boolean, + isShizukuInstalled: Boolean, + onGrantViaShizuku: () -> Unit, onNext: () -> Unit, onExit: () -> Unit, onOpenSettings: () -> Unit, @@ -118,6 +120,14 @@ internal fun DeveloperModeStep( onOpenDeveloperSettings = onOpenDeveloperSettings ) + Spacer(modifier = Modifier.height(16.dp)) + + // Shizuku suggestion card, visible in all setup steps when installed + ShizukuOptionCard( + isVisible = isShizukuInstalled, + onClick = onGrantViaShizuku + ) + Spacer(modifier = Modifier.weight(1f)) Row( @@ -278,6 +288,8 @@ private fun UsbDebuggingCard( @Composable internal fun ConnectUsbStep( isUsbConnected: Boolean, + isShizukuInstalled: Boolean, + onGrantViaShizuku: () -> Unit, onNext: () -> Unit, onExit: () -> Unit, ) { @@ -322,12 +334,17 @@ internal fun ConnectUsbStep( stringResource(id = R.string.permission_wizard_usb_not_connected), isWaiting = !isUsbConnected ) - Spacer(modifier = Modifier.height(24.dp)) + ShizukuOptionCard( + isVisible = isShizukuInstalled, + onClick = onGrantViaShizuku + ) + + Spacer(modifier = Modifier.height(12.dp)) + ConnectionWhySection() - Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(12.dp)) Row( @@ -402,16 +419,19 @@ private fun ConnectionWhySection() { internal fun GrantPermissionStep( adbCommand: String, hasWriteSecureSettings: Boolean, + isShizukuInstalled: Boolean, + onGrantViaShizuku: () -> Unit, onCopyAdbCommand: () -> Unit, onShareSetupUrl: () -> Unit, onShareExpertCommand: () -> Unit, onCheckPermission: () -> Unit, - onExit: () -> Unit + onExit: () -> Unit, ) { val haptic = LocalHapticFeedback.current val pulseScale = remember { Animatable(0.8f) } - LaunchedEffect(!hasWriteSecureSettings) { + + LaunchedEffect(hasWriteSecureSettings) { if (!hasWriteSecureSettings) { pulseScale.animateTo( targetValue = 1.2f, @@ -421,13 +441,7 @@ internal fun GrantPermissionStep( ) ) } else { - pulseScale.snapTo(0.8f) - } - } - - LaunchedEffect(hasWriteSecureSettings) { - if (hasWriteSecureSettings) { - haptic.performHapticFeedback(HapticFeedbackType.Confirm) + pulseScale.snapTo(1.0f) } } @@ -459,8 +473,14 @@ internal fun GrantPermissionStep( pulseScale = pulseScale ) - Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) + + ShizukuOptionCard( + isVisible = isShizukuInstalled, + onClick = onGrantViaShizuku + ) + + Spacer(modifier = Modifier.height(16.dp)) ForExpertsSection( adbCommand = adbCommand, diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt index 3961a729..b4f56094 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt @@ -49,6 +49,8 @@ fun PermissionSetupWizardScreen( hasWriteSecureSettings: Boolean, isDeveloperOptionsEnabled: Boolean, isUsbDebuggingEnabled: Boolean, + isShizukuInstalled: Boolean, + onGrantViaShizuku: () -> Unit, onNext: () -> Unit, onExit: () -> Unit, onOpenSettings: () -> Unit, @@ -120,6 +122,8 @@ fun PermissionSetupWizardScreen( PermissionWizardStep.ENABLE_DEVELOPER_MODE -> DeveloperModeStep( isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, isUsbDebuggingEnabled = isUsbDebuggingEnabled, + isShizukuInstalled = isShizukuInstalled, + onGrantViaShizuku = onGrantViaShizuku, onNext = onNext, onExit = onExit, onOpenSettings = onOpenSettings, @@ -128,6 +132,8 @@ fun PermissionSetupWizardScreen( PermissionWizardStep.CONNECT_USB -> ConnectUsbStep( isUsbConnected = isUsbConnected, + isShizukuInstalled = isShizukuInstalled, + onGrantViaShizuku = onGrantViaShizuku, onNext = onNext, onExit = onExit ) @@ -135,6 +141,8 @@ fun PermissionSetupWizardScreen( PermissionWizardStep.GRANT_PERMISSION -> GrantPermissionStep( adbCommand = adbCommand, hasWriteSecureSettings = hasWriteSecureSettings, + isShizukuInstalled = isShizukuInstalled, + onGrantViaShizuku = onGrantViaShizuku, onCopyAdbCommand = onCopyAdbCommand, onShareSetupUrl = onShareSetupUrl, onShareExpertCommand = onShareExpertCommand, diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/ShizukuOptionCard.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/ShizukuOptionCard.kt new file mode 100644 index 00000000..7a3de458 --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/ShizukuOptionCard.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2025 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.lexip.hecate.R + +@Composable +internal fun ShizukuOptionCard( + isVisible: Boolean, + onClick: () -> Unit, +) { + if (!isVisible) return + + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(id = R.string.permission_wizard_shizuku_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.permission_wizard_shizuku_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = onClick + ) { + Text( + text = stringResource(id = R.string.permission_wizard_shizuku_action), + textAlign = TextAlign.Center + ) + } + } + } +} + diff --git a/app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt b/app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt index 889e93f6..7a60f281 100644 --- a/app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt +++ b/app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt @@ -13,7 +13,6 @@ package dev.lexip.hecate.util import android.content.Context -import android.os.Build import android.util.Log object InstallSourceChecker { @@ -36,12 +35,7 @@ object InstallSourceChecker { val pm = context.packageManager val packageName = context.packageName return try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - pm.getInstallSourceInfo(packageName).installingPackageName - } else { - @Suppress("DEPRECATION") - pm.getInstallerPackageName(packageName) - } + pm.getInstallSourceInfo(packageName).installingPackageName } catch (t: Throwable) { Log.w(TAG, "Failed to resolve installer package", t) null diff --git a/app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt b/app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt new file mode 100644 index 00000000..ec7eb495 --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt @@ -0,0 +1,59 @@ +package dev.lexip.hecate.util.shizuku + +import android.os.Binder +import android.os.Parcel +import android.util.Log +import java.io.BufferedReader +import java.io.InputStreamReader + +class GrantService : Binder() { + + companion object { + private const val TAG = "GrantService" + private const val DESCRIPTOR = "dev.lexip.hecate.util.shizuku.GrantService" + private const val TRANSACTION_EXECUTE_CMD = FIRST_CALL_TRANSACTION + 1 + } + + override fun onTransact( + code: Int, + data: Parcel, + reply: Parcel?, + flags: Int + ): Boolean { + return when (code) { + INTERFACE_TRANSACTION -> { + reply?.writeString(DESCRIPTOR) + true + } + + TRANSACTION_EXECUTE_CMD -> { + data.enforceInterface(DESCRIPTOR) + val cmd = data.readString() + Log.d(TAG, "Received command via Shizuku: $cmd") + val exitCode = runShell(cmd) + reply?.writeInt(exitCode) + true + } + + else -> super.onTransact(code, data, reply, flags) + } + } + + private fun runShell(cmd: String?): Int { + if (cmd.isNullOrBlank()) return -1 + + return try { + val proc = Runtime.getRuntime().exec(arrayOf("sh", "-c", cmd)) + val out = BufferedReader(InputStreamReader(proc.inputStream)) + val err = BufferedReader(InputStreamReader(proc.errorStream)) + + out.lineSequence().forEach { Log.d(TAG, "shizuku-out: $it") } + err.lineSequence().forEach { Log.w(TAG, "shizuku-err: $it") } + + proc.waitFor() + } catch (t: Throwable) { + Log.e(TAG, "Shell execution failed", t) + -1 + } + } +} diff --git a/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuAvailability.kt b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuAvailability.kt new file mode 100644 index 00000000..980dcd4b --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuAvailability.kt @@ -0,0 +1,22 @@ +package dev.lexip.hecate.util.shizuku + +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log + +object ShizukuAvailability { + + private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + private const val TAG = "ShizukuAvailability" + + fun isShizukuInstalled(context: Context): Boolean { + return try { + context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0) + Log.d(TAG, "Found Shizuku package: $SHIZUKU_PACKAGE") + true + } catch (_: PackageManager.NameNotFoundException) { + Log.d(TAG, "Shizuku package not found: $SHIZUKU_PACKAGE") + false + } + } +} diff --git a/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt new file mode 100644 index 00000000..308a477d --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt @@ -0,0 +1,169 @@ +package dev.lexip.hecate.util.shizuku + +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.Binder +import android.os.IBinder +import android.os.Parcel +import android.util.Log +import rikka.shizuku.Shizuku + +object ShizukuManager { + + private const val TAG = "ShizukuManager" + private const val REQUEST_CODE = 1001 + + @Volatile + private var binderReady: Boolean = false + + sealed class GrantResult { + object Success : GrantResult() + object ServiceNotRunning : GrantResult() + object NotAuthorized : GrantResult() + data class ShellCommandFailed(val exitCode: Int) : GrantResult() + data class Unexpected(val error: Throwable) : GrantResult() + } + + init { + try { + Shizuku.addBinderReceivedListener { onBinderReceived() } + Shizuku.addBinderDeadListener { onBinderDead() } + } catch (t: Throwable) { + Log.w(TAG, "Failed to register Shizuku binder listeners", t) + } + } + + private fun onBinderReceived() { + binderReady = true + } + + private fun onBinderDead() { + binderReady = false + } + + fun isBinderReady(): Boolean = binderReady + + fun hasPermission(): Boolean { + if (Shizuku.isPreV11()) return false + if (!binderReady) return false + + return try { + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + } catch (t: Throwable) { + Log.w(TAG, "Failed to check Shizuku permission", t) + false + } + } + + fun requestPermission(@Suppress("UNUSED_PARAMETER") context: Context) { + if (Shizuku.isPreV11()) { + Log.w(TAG, "Ignoring Shizuku.requestPermission on pre-v11") + return + } + + if (!binderReady) { + Log.w(TAG, "Binder not ready, skipping Shizuku.requestPermission") + return + } + + if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { + return + } + + if (Shizuku.shouldShowRequestPermissionRationale()) { + return + } + + Shizuku.requestPermission(REQUEST_CODE) + } + + fun buildGrantWriteSecureSettingsCommand(packageName: String): String = + "pm grant $packageName android.permission.WRITE_SECURE_SETTINGS" + + fun executeGrantViaShizuku(packageName: String): GrantResult { + if (Shizuku.isPreV11()) return GrantResult.ServiceNotRunning + if (!binderReady) return GrantResult.ServiceNotRunning + if (!hasPermission()) return GrantResult.NotAuthorized + + val cmd = buildGrantWriteSecureSettingsCommand(packageName) + + return try { + val monitor = Object() + var result: GrantResult = GrantResult.Unexpected(IllegalStateException("No result")) + + val component = ComponentName( + "dev.lexip.hecate", + GrantService::class.java.name + ) + val args = Shizuku.UserServiceArgs(component) + .processNameSuffix("shizuku_grant") + + val connection = object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + binder: IBinder? + ) { + if (binder == null) { + result = GrantResult.ServiceNotRunning + synchronized(monitor) { monitor.notifyAll() } + return + } + + try { + val data = Parcel.obtain() + val reply = Parcel.obtain() + try { + data.writeInterfaceToken("dev.lexip.hecate.util.shizuku.GrantService") + data.writeString(cmd) + val transactionCode = Binder.FIRST_CALL_TRANSACTION + 1 + val success = binder.transact(transactionCode, data, reply, 0) + result = if (success) { + val exitCode = reply.readInt() + if (exitCode == 0) GrantResult.Success + else GrantResult.ShellCommandFailed(exitCode) + } else { + GrantResult.ServiceNotRunning + } + } finally { + data.recycle() + reply.recycle() + } + } catch (t: Throwable) { + result = when (t) { + is SecurityException -> GrantResult.NotAuthorized + else -> GrantResult.Unexpected(t) + } + } finally { + try { + Shizuku.unbindUserService(args, this, true) + } catch (t: Throwable) { + Log.w(TAG, "Error while unbinding Shizuku user service", t) + } + synchronized(monitor) { monitor.notifyAll() } + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "GrantService disconnected: $name") + } + } + + Shizuku.bindUserService(args, connection) + + synchronized(monitor) { + try { + monitor.wait(5000) + } catch (t: InterruptedException) { + Log.w(TAG, "Interrupted while waiting for Shizuku user service", t) + } + } + + result + } catch (t: Throwable) { + Log.e(TAG, "Grant via Shizuku failed", t) + GrantResult.Unexpected(t) + } + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 332f3c12..a9ccd32d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -68,15 +68,26 @@ USB-Debugging aktivieren USB-Debugging aktiviert Aktiviere USB-Debugging - Warte auf USB-Verbindung... + Warte auf USB-Verbindung… Warum ist ein anderes Gerät nötig? Android verhindert, dass Apps sich die benötigte Berechtigung selbst erteilen. Es wird ein anderes Gerät mit einem Webbrowser oder ADB benötigt. Ist das sicher? Ja. Es erlaubt der App nur, Einstellungen wie den Dunkelmodus zu ändern. Das ist absolut sicher und vollständig rückgängig zu machen. + Alternative: Shizuku + Wenn du Shizuku bereits verwendest, benötigst du weder ein zweites Gerät noch ADB. + Shizuku verwenden + Shizuku erkannt: Du kannst die Berechtigung damit direkt von diesem Gerät aus erteilen. Einrichtung erforderlich %1$s benötigt eine einmalige Einrichtung, um zu funktionieren. Einrichtung starten - Live-Wert + Live-Wert + Shizuku-Version nicht unterstützt. + Starte Shizuku und versuche es erneut. + Aktiviere Adaptive Theme in Shizuku. Zugriff verweigert. + Frage Berechtigung an… + Einrichtung abgeschlossen. + Shizuku konnte den Command nicht ausführen. + Unerwarteter Fehler mit Shizuku. \ 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 8a82b922..3ca025ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ About Brightness Threshold + Live Value Change Language Current Brightness Custom Threshold @@ -72,15 +73,25 @@ Enable USB Debugging USB Debugging enabled Enable USB Debugging - Waiting for USB connection... + Waiting for USB connection… Why is another device required? Android prevents apps from granting the required permission themselves. It requires another device with either a web browser or ADB installed. Is this safe? Yes. It just allows the app to modify settings like the dark mode. This is absolutely safe and completely reversible. + Alternative: Shizuku + If you already use Shizuku, you won\'t need a second device or ADB. + Use Shizuku instead + Shizuku detected: you can use it to grant the permission directly from this device. Setup Required %1$s requires a one-time setup to function. Start Setup - Live Value + Shizuku version not supported. + Start Shizuku and try again. + Enable Adaptive Theme in Shizuku. Access denied. + Requesting permission… + Setup completed. + Shizuku could not run the command. + Unexpected error while using Shizuku. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ed0222c..e1316384 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ lifecycleViewmodelCompose = "2.10.0" localbroadcastmanager = "1.1.0" material = "1.13.0" preference = "1.2.1" +shizuku = "13.1.5" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -48,6 +49,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref = "firebaseAnalytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" } +shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } +shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 2a19718c38befcde7070d499c13646846ce8845f Mon Sep 17 00:00:00 2001 From: xLexip Date: Mon, 8 Dec 2025 19:58:06 +0100 Subject: [PATCH 07/23] refactor(ui): Optimize setup modularization --- .../hecate/ui/setup/PermissionSetupHost.kt | 12 + .../hecate/ui/setup/PermissionSetupSteps.kt | 593 ------------------ .../ui/setup/PermissionSetupWizardScreen.kt | 3 + .../lexip/hecate/ui/setup/ShareExtensions.kt | 12 + .../hecate/ui/setup/components/FaqCards.kt | 85 +++ .../{ => components}/ForExpertsSection.kt | 9 +- .../{ => components}/ShizukuOptionCard.kt | 9 +- .../ui/setup/components/StepNavigationRow.kt | 61 ++ .../WaitingCard.kt} | 80 ++- .../hecate/ui/setup/steps/ConnectUsbStep.kt | 106 ++++ .../ui/setup/steps/DeveloperModeStep.kt | 266 ++++++++ .../ui/setup/steps/GrantPermissionStep.kt | 147 +++++ 12 files changed, 738 insertions(+), 645 deletions(-) delete mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt rename app/src/main/java/dev/lexip/hecate/ui/setup/{ => components}/ForExpertsSection.kt (94%) rename app/src/main/java/dev/lexip/hecate/ui/setup/{ => components}/ShizukuOptionCard.kt (89%) create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt rename app/src/main/java/dev/lexip/hecate/ui/setup/{PermissionSetupCommon.kt => components/WaitingCard.kt} (69%) create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt index 4403c022..f21d1c16 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt @@ -1,3 +1,15 @@ +/* + * Copyright (C) 2025 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + package dev.lexip.hecate.ui.setup import android.Manifest diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt deleted file mode 100644 index aeffebc8..00000000 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt +++ /dev/null @@ -1,593 +0,0 @@ -/* - * Copyright (C) 2025 xLexip - * - * Licensed under the GNU General Public License, Version 3.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.gnu.org/licenses/gpl-3.0 - * - * Please see the License for specific terms regarding permissions and limitations. - */ - -package dev.lexip.hecate.ui.setup - -import android.widget.Toast -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Arrangement -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.outlined.Circle -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import dev.lexip.hecate.R - -@Composable -internal fun DeveloperModeStep( - isDeveloperOptionsEnabled: Boolean, - isUsbDebuggingEnabled: Boolean, - isShizukuInstalled: Boolean, - onGrantViaShizuku: () -> Unit, - onNext: () -> Unit, - onExit: () -> Unit, - onOpenSettings: () -> Unit, - onOpenDeveloperSettings: () -> Unit, -) { - val haptic = LocalHapticFeedback.current - val bothEnabled = isDeveloperOptionsEnabled && isUsbDebuggingEnabled - - LaunchedEffect(isDeveloperOptionsEnabled) { - if (isDeveloperOptionsEnabled) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - } - } - - LaunchedEffect(isUsbDebuggingEnabled) { - if (isUsbDebuggingEnabled) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - } - } - - LaunchedEffect(bothEnabled) { - if (bothEnabled) { - onNext() - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - Text( - text = stringResource(id = R.string.permission_wizard_developer_mode_title), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_developer_mode_body), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(24.dp)) - - DeveloperOptionsCard( - isEnabled = isDeveloperOptionsEnabled, - onOpenSettings = onOpenSettings - ) - - Spacer(modifier = Modifier.height(16.dp)) - - UsbDebuggingCard( - isEnabled = isUsbDebuggingEnabled, - isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, - onOpenDeveloperSettings = onOpenDeveloperSettings - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Shizuku suggestion card, visible in all setup steps when installed - ShizukuOptionCard( - isVisible = isShizukuInstalled, - onClick = onGrantViaShizuku - ) - - Spacer(modifier = Modifier.weight(1f)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - OutlinedButton(onClick = onExit) { - Text(text = stringResource(id = R.string.action_close)) - } - Button( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - onNext() - }, - enabled = bothEnabled - ) { - Text(text = stringResource(id = R.string.action_continue)) - } - } - } -} - -@Composable -private fun DeveloperOptionsCard( - isEnabled: Boolean, - onOpenSettings: () -> Unit, -) { - val context = LocalContext.current - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = if (isEnabled) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surface - ) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - if (isEnabled) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(16.dp)) - } - Text( - text = stringResource( - id = if (isEnabled) - R.string.permission_wizard_developer_options_enabled - else - R.string.permission_wizard_developer_options_title - ), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = if (isEnabled) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - } - if (!isEnabled) { - Spacer(modifier = Modifier.height(12.dp)) - val toastText = stringResource(R.string.permission_wizard_dev_options_toast) - Button( - onClick = { - onOpenSettings() - Toast.makeText( - context, - toastText, - Toast.LENGTH_LONG - ).show() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.permission_wizard_action_open_settings)) - } - } - } - } -} - -@Composable -private fun UsbDebuggingCard( - isEnabled: Boolean, - isDeveloperOptionsEnabled: Boolean, - onOpenDeveloperSettings: () -> Unit, -) { - val context = LocalContext.current - val usbDebuggingToastText = stringResource(R.string.permission_wizard_usb_debugging_toast) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = if (isEnabled) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surface - ) - ) { - Column(modifier = Modifier.padding(20.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - if (isEnabled) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(16.dp)) - } - Text( - text = stringResource( - id = if (isEnabled) - R.string.permission_wizard_usb_debugging_enabled - else - R.string.permission_wizard_usb_debugging_disabled - ), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = if (isEnabled) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - } - if (!isEnabled && isDeveloperOptionsEnabled) { - Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = { - onOpenDeveloperSettings() - Toast.makeText( - context, - usbDebuggingToastText, - Toast.LENGTH_LONG - ).show() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.permission_wizard_action_open_developer_settings)) - } - } - } - } -} - -@Composable -internal fun ConnectUsbStep( - isUsbConnected: Boolean, - isShizukuInstalled: Boolean, - onGrantViaShizuku: () -> Unit, - onNext: () -> Unit, - onExit: () -> Unit, -) { - val haptic = LocalHapticFeedback.current - - LaunchedEffect(isUsbConnected) { - if (isUsbConnected) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - } - } - - LaunchedEffect(isUsbConnected) { - if (isUsbConnected) { - onNext() - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - Text( - text = stringResource(id = R.string.permission_wizard_connect_title), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_connect_body), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(24.dp)) - - StatusCard( - isCompleted = isUsbConnected, - title = if (isUsbConnected) - stringResource(id = R.string.permission_wizard_usb_connected) - else - stringResource(id = R.string.permission_wizard_usb_not_connected), - isWaiting = !isUsbConnected - ) - Spacer(modifier = Modifier.height(24.dp)) - - ShizukuOptionCard( - isVisible = isShizukuInstalled, - onClick = onGrantViaShizuku - ) - - Spacer(modifier = Modifier.height(12.dp)) - - ConnectionWhySection() - - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - OutlinedButton(onClick = onExit) { - Text(text = stringResource(id = R.string.action_close)) - } - OutlinedButton( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - onNext() - } - ) { - Text(text = stringResource(id = R.string.action_skip)) - } - } - } -} - -@Composable -private fun ConnectionWhySection() { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.permission_wizard_why_other_device_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_why_other_device), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.permission_wizard_is_this_safe_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_is_this_safe_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -internal fun GrantPermissionStep( - adbCommand: String, - hasWriteSecureSettings: Boolean, - isShizukuInstalled: Boolean, - onGrantViaShizuku: () -> Unit, - onCopyAdbCommand: () -> Unit, - onShareSetupUrl: () -> Unit, - onShareExpertCommand: () -> Unit, - onCheckPermission: () -> Unit, - onExit: () -> Unit, -) { - val haptic = LocalHapticFeedback.current - - val pulseScale = remember { Animatable(0.8f) } - - LaunchedEffect(hasWriteSecureSettings) { - if (!hasWriteSecureSettings) { - pulseScale.animateTo( - targetValue = 1.2f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 750, easing = LinearOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ) - ) - } else { - pulseScale.snapTo(1.0f) - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - Text( - text = stringResource(id = R.string.permission_wizard_grant_title), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_grant_body), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(24.dp)) - - WebsiteShareCard(onShareSetupUrl = onShareSetupUrl) - - Spacer(modifier = Modifier.height(20.dp)) - - PermissionStatusSection( - hasWriteSecureSettings = hasWriteSecureSettings, - pulseScale = pulseScale - ) - - Spacer(modifier = Modifier.height(16.dp)) - - ShizukuOptionCard( - isVisible = isShizukuInstalled, - onClick = onGrantViaShizuku - ) - - Spacer(modifier = Modifier.height(16.dp)) - - ForExpertsSection( - adbCommand = adbCommand, - onCopyAdbCommand = onCopyAdbCommand, - onShareExpertCommand = onShareExpertCommand - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - OutlinedButton(onClick = onExit) { - Text(text = stringResource(id = R.string.action_close)) - } - Button( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - onCheckPermission() - }, - enabled = hasWriteSecureSettings - ) { - Text(text = stringResource(id = R.string.action_finish)) - } - } - } -} - -@Composable -private fun WebsiteShareCard( - onShareSetupUrl: () -> Unit, -) { - val haptic = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(id = R.string.permission_wizard_website_url), - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(modifier = Modifier.height(16.dp)) - OutlinedButton( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - onShareSetupUrl() - }, - modifier = Modifier.wrapContentWidth(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) - ) { - Text(text = stringResource(id = R.string.action_share_url)) - } - } - } -} - -@Composable -private fun PermissionStatusSection( - hasWriteSecureSettings: Boolean, - pulseScale: Animatable, -) { - if (hasWriteSecureSettings) { - StatusCard( - isCompleted = true, - title = stringResource(id = R.string.permission_wizard_permission_granted) - ) - } else { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = null, - modifier = Modifier - .size(24.dp) - .scale(pulseScale.value), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_permission_not_granted), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt index b4f56094..17946be4 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt @@ -33,6 +33,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import dev.lexip.hecate.R +import dev.lexip.hecate.ui.setup.steps.ConnectUsbStep +import dev.lexip.hecate.ui.setup.steps.DeveloperModeStep +import dev.lexip.hecate.ui.setup.steps.GrantPermissionStep enum class PermissionWizardStep { ENABLE_DEVELOPER_MODE, diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/ShareExtensions.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/ShareExtensions.kt index 3e5b8e05..36ea871a 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/ShareExtensions.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/ShareExtensions.kt @@ -1,3 +1,15 @@ +/* + * Copyright (C) 2025 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + package dev.lexip.hecate.ui.setup import android.content.Intent diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt new file mode 100644 index 00000000..111b9afe --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2025 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.lexip.hecate.R + +@Composable +internal fun SetupFAQCards() { + WhyOtherDeviceCard() + IsThisSafeCard() +} + +@Composable +internal fun WhyOtherDeviceCard() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(id = R.string.permission_wizard_why_other_device_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.permission_wizard_why_other_device), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +internal fun IsThisSafeCard() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(id = R.string.permission_wizard_is_this_safe_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.permission_wizard_is_this_safe_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt similarity index 94% rename from app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt rename to app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt index 91905a54..dfb236de 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/ForExpertsSection.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt @@ -10,7 +10,7 @@ * Please see the License for specific terms regarding permissions and limitations. */ -package dev.lexip.hecate.ui.setup +package dev.lexip.hecate.ui.setup.components import android.widget.Toast import androidx.compose.foundation.clickable @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -48,7 +49,7 @@ import androidx.compose.ui.unit.dp import dev.lexip.hecate.R @Composable -internal fun ForExpertsSection( +internal fun ForExpertsSectionCard( adbCommand: String?, onCopyAdbCommand: (() -> Unit)? = null, onShareExpertCommand: (() -> Unit)? = null, @@ -72,7 +73,7 @@ internal fun ForExpertsSection( ) { Text( text = stringResource(id = R.string.permission_wizard_for_experts), - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f) @@ -89,7 +90,7 @@ internal fun ForExpertsSection( Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(id = R.string.permission_wizard_manual_command), - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/ShizukuOptionCard.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ShizukuOptionCard.kt similarity index 89% rename from app/src/main/java/dev/lexip/hecate/ui/setup/ShizukuOptionCard.kt rename to app/src/main/java/dev/lexip/hecate/ui/setup/components/ShizukuOptionCard.kt index 7a3de458..cbe83dd0 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/ShizukuOptionCard.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ShizukuOptionCard.kt @@ -10,7 +10,7 @@ * Please see the License for specific terms regarding permissions and limitations. */ -package dev.lexip.hecate.ui.setup +package dev.lexip.hecate.ui.setup.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -40,7 +40,7 @@ internal fun ShizukuOptionCard( ElevatedCard( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = MaterialTheme.colorScheme.secondaryContainer, ) ) { Column(modifier = Modifier.padding(16.dp)) { @@ -48,13 +48,13 @@ internal fun ShizukuOptionCard( text = stringResource(id = R.string.permission_wizard_shizuku_title), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSecondaryContainer ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(id = R.string.permission_wizard_shizuku_body), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSecondaryContainer ) Spacer(modifier = Modifier.height(8.dp)) OutlinedButton( @@ -69,4 +69,3 @@ internal fun ShizukuOptionCard( } } } - diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt new file mode 100644 index 00000000..0d2ef529 --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt @@ -0,0 +1,61 @@ +package dev.lexip.hecate.ui.setup.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +@Composable +internal fun StepNavigationRow( + leftTextRes: Int, + onLeft: () -> Unit, + rightTextRes: Int, + onRight: () -> Unit, + rightEnabled: Boolean = true, + rightIsPrimary: Boolean = true, +) { + val haptic = LocalHapticFeedback.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + OutlinedButton(onClick = onLeft) { + Text(text = stringResource(id = leftTextRes)) + } + + if (rightIsPrimary) { + Button( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onRight() + }, + enabled = rightEnabled + ) { + Text(text = stringResource(id = rightTextRes)) + } + } else { + OutlinedButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onRight() + } + ) { + Text(text = stringResource(id = rightTextRes)) + } + } + } +} + diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/WaitingCard.kt similarity index 69% rename from app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt rename to app/src/main/java/dev/lexip/hecate/ui/setup/components/WaitingCard.kt index 45cdd824..4290a95b 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/WaitingCard.kt @@ -10,7 +10,7 @@ * Please see the License for specific terms regarding permissions and limitations. */ -package dev.lexip.hecate.ui.setup +package dev.lexip.hecate.ui.setup.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearOutSlowInEasing @@ -22,12 +22,10 @@ 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.outlined.Circle import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -44,26 +42,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @Composable -internal fun StatusCard( - isCompleted: Boolean, - title: String, - onClick: (() -> Unit)? = null, - isWaiting: Boolean = false -) { - val cardColors = if (isCompleted) { - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - ) - } else { - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ) - } - +internal fun rememberPulseScale(isActive: Boolean): Float { val pulseScale = remember { Animatable(0.8f) } - LaunchedEffect(isWaiting) { - if (isWaiting) { + LaunchedEffect(isActive) { + if (isActive) { pulseScale.animateTo( targetValue = 1.2f, animationSpec = infiniteRepeatable( @@ -76,12 +59,39 @@ internal fun StatusCard( } } + return pulseScale.value +} + +@Composable +internal fun WaitingCircle( + modifier: Modifier = Modifier, + pulseScale: Float, +) { + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = null, + modifier = modifier + .size(32.dp) + .scale(pulseScale), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@Composable +internal fun SetupWaitingCard( + title: String, + pulseScale: Float, + onClick: (() -> Unit)? = null, +) { + val cardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ) + Card( onClick = onClick ?: {}, enabled = onClick != null, modifier = Modifier - .fillMaxWidth() - .height(80.dp), + .fillMaxWidth(), colors = cardColors ) { Row( @@ -95,31 +105,15 @@ internal fun StatusCard( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f) ) { - val icon = if (isCompleted && !isWaiting) { - Icons.Filled.CheckCircle - } else { - Icons.Outlined.Circle - } - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier - .size(32.dp) - .scale(pulseScale.value), - tint = if (isCompleted && !isWaiting) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.onSurfaceVariant + WaitingCircle( + pulseScale = pulseScale, ) Spacer(modifier = Modifier.width(16.dp)) Text( text = title, style = MaterialTheme.typography.titleMedium, - fontWeight = if (isCompleted) FontWeight.Medium else FontWeight.Normal, - color = if (isCompleted) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurface + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface ) } } diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt new file mode 100644 index 00000000..1457409f --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2025 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup.steps + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.lexip.hecate.R +import dev.lexip.hecate.ui.setup.components.SetupFAQCards +import dev.lexip.hecate.ui.setup.components.SetupWaitingCard +import dev.lexip.hecate.ui.setup.components.ShizukuOptionCard +import dev.lexip.hecate.ui.setup.components.StepNavigationRow +import dev.lexip.hecate.ui.setup.components.rememberPulseScale + +@Composable +internal fun ConnectUsbStep( + isUsbConnected: Boolean, + isShizukuInstalled: Boolean, + onGrantViaShizuku: () -> Unit, + onNext: () -> Unit, + onExit: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + + LaunchedEffect(isUsbConnected) { + if (isUsbConnected) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + } + + LaunchedEffect(isUsbConnected) { + if (isUsbConnected) { + onNext() + } + } + + val pulseScale = rememberPulseScale(isActive = !isUsbConnected) + + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(id = R.string.permission_wizard_connect_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(id = R.string.permission_wizard_connect_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + SetupWaitingCard( + title = stringResource(id = R.string.permission_wizard_usb_not_connected), + pulseScale = pulseScale + ) + + ShizukuOptionCard( + isVisible = isShizukuInstalled, + onClick = onGrantViaShizuku + ) + + SetupFAQCards() + } + + StepNavigationRow( + leftTextRes = R.string.action_close, + onLeft = onExit, + rightTextRes = R.string.action_skip, + onRight = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onNext() + }, + rightEnabled = true, + rightIsPrimary = false + ) + } +} diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt new file mode 100644 index 00000000..5a1585ab --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2025 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup.steps + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.lexip.hecate.R +import dev.lexip.hecate.ui.setup.components.ShizukuOptionCard +import dev.lexip.hecate.ui.setup.components.StepNavigationRow + +@Composable +internal fun DeveloperModeStep( + isDeveloperOptionsEnabled: Boolean, + isUsbDebuggingEnabled: Boolean, + isShizukuInstalled: Boolean, + onGrantViaShizuku: () -> Unit, + onNext: () -> Unit, + onExit: () -> Unit, + onOpenSettings: () -> Unit, + onOpenDeveloperSettings: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + val bothEnabled = isDeveloperOptionsEnabled && isUsbDebuggingEnabled + + LaunchedEffect(isDeveloperOptionsEnabled) { + if (isDeveloperOptionsEnabled) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + } + + LaunchedEffect(isUsbDebuggingEnabled) { + if (isUsbDebuggingEnabled) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + } + + LaunchedEffect(bothEnabled) { + if (bothEnabled) { + onNext() + } + } + + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(id = R.string.permission_wizard_developer_mode_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(id = R.string.permission_wizard_developer_mode_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + DeveloperOptionsCard( + isEnabled = isDeveloperOptionsEnabled, + onOpenSettings = onOpenSettings + ) + + UsbDebuggingCard( + isEnabled = isUsbDebuggingEnabled, + isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, + onOpenDeveloperSettings = onOpenDeveloperSettings + ) + + ShizukuOptionCard( + isVisible = isShizukuInstalled, + onClick = onGrantViaShizuku + ) + } + + StepNavigationRow( + leftTextRes = R.string.action_close, + onLeft = onExit, + rightTextRes = R.string.action_continue, + onRight = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onNext() + }, + rightEnabled = bothEnabled, + rightIsPrimary = true + ) + } +} + +@Composable +private fun DeveloperOptionsCard( + isEnabled: Boolean, + onOpenSettings: () -> Unit, +) { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isEnabled) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (isEnabled) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(16.dp)) + } + Text( + text = stringResource( + id = if (isEnabled) + R.string.permission_wizard_developer_options_enabled + else + R.string.permission_wizard_developer_options_title + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isEnabled) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } + if (!isEnabled) { + Spacer(modifier = Modifier.height(12.dp)) + val toastText = stringResource(R.string.permission_wizard_dev_options_toast) + Button( + onClick = { + onOpenSettings() + Toast.makeText( + context, + toastText, + Toast.LENGTH_LONG + ).show() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.permission_wizard_action_open_settings)) + } + } + } + } +} + +@Composable +private fun UsbDebuggingCard( + isEnabled: Boolean, + isDeveloperOptionsEnabled: Boolean, + onOpenDeveloperSettings: () -> Unit, +) { + val context = LocalContext.current + val usbDebuggingToastText = stringResource(R.string.permission_wizard_usb_debugging_toast) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isEnabled) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surface + ) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (isEnabled) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(16.dp)) + } + Text( + text = stringResource( + id = if (isEnabled) + R.string.permission_wizard_usb_debugging_enabled + else + R.string.permission_wizard_usb_debugging_disabled + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isEnabled) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } + if (!isEnabled && isDeveloperOptionsEnabled) { + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { + onOpenDeveloperSettings() + Toast.makeText( + context, + usbDebuggingToastText, + Toast.LENGTH_LONG + ).show() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.permission_wizard_action_open_developer_settings)) + } + } + } + } +} diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt new file mode 100644 index 00000000..ce7169a6 --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2025 xLexip + * + * Licensed under the GNU General Public License, Version 3.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.gnu.org/licenses/gpl-3.0 + * + * Please see the License for specific terms regarding permissions and limitations. + */ + +package dev.lexip.hecate.ui.setup.steps + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.lexip.hecate.R +import dev.lexip.hecate.ui.setup.components.ForExpertsSectionCard +import dev.lexip.hecate.ui.setup.components.SetupWaitingCard +import dev.lexip.hecate.ui.setup.components.StepNavigationRow +import dev.lexip.hecate.ui.setup.components.rememberPulseScale + +@Composable +internal fun GrantPermissionStep( + adbCommand: String, + hasWriteSecureSettings: Boolean, + isShizukuInstalled: Boolean, + onGrantViaShizuku: () -> Unit, + onCopyAdbCommand: () -> Unit, + onShareSetupUrl: () -> Unit, + onShareExpertCommand: () -> Unit, + onCheckPermission: () -> Unit, + onExit: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + + val pulseScale = rememberPulseScale(isActive = !hasWriteSecureSettings) + + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(id = R.string.permission_wizard_grant_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(id = R.string.permission_wizard_grant_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + SetupWebsiteCard(onShareSetupUrl = onShareSetupUrl) + + SetupWaitingCard( + title = stringResource(id = R.string.permission_wizard_permission_not_granted), + pulseScale = pulseScale + ) + + ForExpertsSectionCard( + adbCommand = adbCommand, + onCopyAdbCommand = onCopyAdbCommand, + onShareExpertCommand = onShareExpertCommand + ) + } + + StepNavigationRow( + leftTextRes = R.string.action_close, + onLeft = onExit, + rightTextRes = R.string.action_finish, + onRight = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onCheckPermission() + }, + rightEnabled = hasWriteSecureSettings, + rightIsPrimary = true + ) + } +} + +@Composable +private fun SetupWebsiteCard(onShareSetupUrl: () -> Unit) { + val haptic = LocalHapticFeedback.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.permission_wizard_website_url), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + onShareSetupUrl() + }, + modifier = Modifier.wrapContentWidth() + ) { + Text( + text = stringResource(id = R.string.action_share_url), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } +} From 9c8aed1c515e3fdb13f6163b2536fffa0b392a22 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 09:52:31 +0100 Subject: [PATCH 08/23] feat(setup): Implemented root support * Implements #67 * Offer root as an alternative setup method for experts. --- .../lexip/hecate/ui/AdaptiveThemeViewModel.kt | 66 +++++++++++++++++-- .../hecate/ui/setup/PermissionSetupHost.kt | 32 ++++++--- .../ui/setup/PermissionSetupWizardScreen.kt | 4 +- .../ui/setup/components/ForExpertsSection.kt | 17 +---- .../ui/setup/steps/GrantPermissionStep.kt | 3 +- app/src/main/res/values-de/strings.xml | 7 +- app/src/main/res/values/strings.xml | 7 +- 7 files changed, 102 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt index 9ab415ce..64430749 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import rikka.shizuku.Shizuku +import java.util.concurrent.atomic.AtomicBoolean const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" @@ -290,8 +291,14 @@ class AdaptiveThemeViewModel( } } - fun completePermissionWizardAndEnableService() { + private val permissionWizardCompletionHandled = AtomicBoolean(false) + fun completePermissionWizard( + context: android.content.Context, + source: String? = null + ) { viewModelScope.launch { + if (permissionWizardCompletionHandled.getAndSet(true)) return@launch + if (source != null) AnalyticsLogger.logSetupFinished(context, source) userPreferencesRepository.updatePermissionWizardCompleted(true) dismissPermissionWizard() updateAdaptiveThemeEnabled(true) @@ -430,9 +437,9 @@ class AdaptiveThemeViewModel( when (result) { is ShizukuManager.GrantResult.Success -> { // Setup using Shizuku complete - AnalyticsLogger.logSetupFinished( - application.applicationContext, - source = "shizuku_setup_complete" + completePermissionWizard( + context, + source = "shizuku" ) } @@ -456,7 +463,7 @@ class AdaptiveThemeViewModel( is ShizukuManager.GrantResult.ShellCommandFailed -> { Toast.makeText( context, - context.getString(R.string.shizuku_grant_shell_failed, result.exitCode), + context.getString(R.string.shizuku_grant_shell_failed), Toast.LENGTH_LONG ).show() } @@ -473,6 +480,55 @@ class AdaptiveThemeViewModel( } } + fun onGrantViaRootRequested(context: android.content.Context, packageName: String) { + viewModelScope.launch(ioDispatcher) { + val result = tryGrantViaRoot(packageName) + withContext(mainDispatcher) { + when (result) { + RootGrantResult.Success -> { + completePermissionWizard( + context, + source = "root" + ) + Toast.makeText( + context, + R.string.permission_wizard_permission_granted, + Toast.LENGTH_SHORT + ).show() + } + + is RootGrantResult.Failure -> { + Toast.makeText( + context, + R.string.permission_wizard_root_grant_failed, + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + + private sealed interface RootGrantResult { + data object Success : RootGrantResult + data class Failure(val reason: String) : RootGrantResult + } + + private fun tryGrantViaRoot(packageName: String): RootGrantResult { + return try { + val command = "pm grant $packageName android.permission.WRITE_SECURE_SETTINGS" + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) + val exitCode = process.waitFor() + if (exitCode == 0) { + RootGrantResult.Success + } else { + RootGrantResult.Failure("exit_code_$exitCode") + } + } catch (e: Exception) { + RootGrantResult.Failure(e.javaClass.simpleName) + } + } + private fun openShizukuAppIfInstalled(context: android.content.Context) { val pm = context.packageManager try { diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt index f21d1c16..c473b68b 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt @@ -16,6 +16,7 @@ import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.provider.Settings +import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -28,6 +29,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.core.content.ContextCompat +import dev.lexip.hecate.R import dev.lexip.hecate.analytics.AnalyticsLogger import dev.lexip.hecate.ui.AdaptiveThemeViewModel import dev.lexip.hecate.util.shizuku.ShizukuAvailability @@ -172,7 +174,10 @@ fun PermissionSetupHost( // If permission becomes granted, auto-complete wizard and enable service if (hasPermission) { haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - viewModel.completePermissionWizardAndEnableService() + viewModel.completePermissionWizard( + context, + if (isUsbConnected) "usb" else null + ) break } @@ -213,10 +218,10 @@ fun PermissionSetupHost( PermissionWizardStep.GRANT_PERMISSION -> { if (hasPermission) { - val source = - if (isUsbConnected) "permission_wizard_complete_usb" else "permission_wizard_complete" - AnalyticsLogger.logSetupFinished(context, source = source) - viewModel.completePermissionWizardAndEnableService() + viewModel.completePermissionWizard( + context, + if (isUsbConnected) "usb" else null + ) } else { viewModel.goToNextPermissionWizardStep() } @@ -258,11 +263,20 @@ fun PermissionSetupHost( viewModel.recheckWriteSecureSettingsPermission(nowGranted) if (nowGranted) { haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - val source = - if (isUsbConnected) "permission_wizard_check_now_granted_usb" else "permission_wizard_check_now_granted" - AnalyticsLogger.logSetupFinished(context, source = source) - viewModel.completePermissionWizardAndEnableService() + if (isUsbConnected) AnalyticsLogger.logSetupFinished(context, "usb") + viewModel.completePermissionWizard( + context, + if (isUsbConnected) "usb" else null + ) } + }, + onUseRoot = { + Toast.makeText( + context, + R.string.permission_wizard_root_grant_starting, + Toast.LENGTH_SHORT + ).show() + viewModel.onGrantViaRootRequested(context, context.packageName) } ) } diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt index 17946be4..d2cab0b0 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt @@ -62,6 +62,7 @@ fun PermissionSetupWizardScreen( onCopyAdbCommand: () -> Unit, onShareExpertCommand: () -> Unit, onCheckPermission: () -> Unit, + onUseRoot: () -> Unit, ) { val totalSteps = PermissionWizardStep.entries.size val currentStepIndex = step.ordinal + 1 @@ -150,7 +151,8 @@ fun PermissionSetupWizardScreen( onShareSetupUrl = onShareSetupUrl, onShareExpertCommand = onShareExpertCommand, onCheckPermission = onCheckPermission, - onExit = onExit + onExit = onExit, + onUseRoot = onUseRoot ) } } diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt index dfb236de..8f1fb434 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt @@ -12,7 +12,6 @@ package dev.lexip.hecate.ui.setup.components -import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -41,7 +40,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -51,10 +49,9 @@ import dev.lexip.hecate.R @Composable internal fun ForExpertsSectionCard( adbCommand: String?, - onCopyAdbCommand: (() -> Unit)? = null, + onUseRoot: (() -> Unit)? = null, onShareExpertCommand: (() -> Unit)? = null, ) { - val context = LocalContext.current val haptic = LocalHapticFeedback.current var expanded by remember { mutableStateOf(false) } @@ -93,7 +90,6 @@ internal fun ForExpertsSectionCard( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(12.dp)) Surface( modifier = Modifier.fillMaxWidth(), @@ -107,7 +103,6 @@ internal fun ForExpertsSectionCard( fontWeight = FontWeight.Medium ) } - Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -116,18 +111,12 @@ internal fun ForExpertsSectionCard( OutlinedButton( onClick = { haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - onCopyAdbCommand?.invoke() - Toast.makeText( - context, - R.string.permission_wizard_copied, - Toast.LENGTH_SHORT - ).show() + onUseRoot?.invoke() }, modifier = Modifier.weight(1f) ) { - Text(text = stringResource(id = R.string.action_copy)) + Text(text = stringResource(id = R.string.permission_wizard_action_use_root)) } - Button( onClick = { haptic.performHapticFeedback(HapticFeedbackType.ContextClick) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt index ce7169a6..0bd77ef9 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt @@ -53,6 +53,7 @@ internal fun GrantPermissionStep( onShareExpertCommand: () -> Unit, onCheckPermission: () -> Unit, onExit: () -> Unit, + onUseRoot: (() -> Unit)? = null, ) { val haptic = LocalHapticFeedback.current @@ -87,7 +88,7 @@ internal fun GrantPermissionStep( ForExpertsSectionCard( adbCommand = adbCommand, - onCopyAdbCommand = onCopyAdbCommand, + onUseRoot = onUseRoot, onShareExpertCommand = onShareExpertCommand ) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a9ccd32d..4f54cada 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -57,10 +57,10 @@ Entwickleroptionen aktivieren Entwickleroptionen aktiviert Finde die Build-Nummer und tippe 7-mal darauf, um die Entwickleroptionen zu aktivieren. - Alternative für Experten (ADB) + Alternativen für Experten (ADB oder Root) Berechtigung mit dem anderen Gerät erteilen Öffne den folgenden Link auf deinem anderen Gerät und folge den Anweisungen: - Wenn du ADB auf deinem Computer installiert hast, kannst du stattdessen diesen Command ausführen: + Alternativ kannst du diesen ADB-Command ausführen oder Root benutzen. Shizuku wird ebenfalls unterstützt. Berechtigung erteilt. Berechtigung noch nicht erteilt. Bitte schließe die Einrichtung mit dem anderen Gerät ab. Schritt %1$d von %2$d @@ -77,6 +77,7 @@ Wenn du Shizuku bereits verwendest, benötigst du weder ein zweites Gerät noch ADB. Shizuku verwenden Shizuku erkannt: Du kannst die Berechtigung damit direkt von diesem Gerät aus erteilen. + Root verwenden Einrichtung erforderlich %1$s benötigt eine einmalige Einrichtung, um zu funktionieren. @@ -90,4 +91,6 @@ Einrichtung abgeschlossen. Shizuku konnte den Command nicht ausführen. Unerwarteter Fehler mit Shizuku. + Root-Zugriff angefragt... (beta) + Root-Vergabe fehlgeschlagen. \ 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 3ca025ff..bec3ff33 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,10 +62,10 @@ Enable developer options Developer options enabled Find the Build number and tap it 7 times to enable developer options. - Alternative for experts (ADB) + Alternatives for experts (ADB or Root) Grant permission with the other device Open the following link on your other device and follow the instructions: - If you have ADB installed on your computer, you can run this command instead: + Alternatively, you can execute this ADB command or use root to grant the permission. Shizuku is also supported. Permission granted. Permission not yet granted. Please complete the setup with the other device. Step %1$d of %2$d @@ -82,6 +82,7 @@ If you already use Shizuku, you won\'t need a second device or ADB. Use Shizuku instead Shizuku detected: you can use it to grant the permission directly from this device. + Use Root Setup Required %1$s requires a one-time setup to function. @@ -94,4 +95,6 @@ Setup completed. Shizuku could not run the command. Unexpected error while using Shizuku. + Requesting root access... (beta) + Root grant failed. \ No newline at end of file From b1ad3b6022a299afdf97cd79bf44db757aa0d088 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 09:53:49 +0100 Subject: [PATCH 09/23] refactor(setup): Optimize the look of the setup expert card --- .../ui/setup/components/ForExpertsSection.kt | 24 ++++--------------- .../ui/setup/steps/GrantPermissionStep.kt | 3 +-- app/src/main/res/values/strings.xml | 1 + 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt index 8f1fb434..1f070768 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt @@ -23,14 +23,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandLess import androidx.compose.material.icons.outlined.ExpandMore -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -48,9 +46,8 @@ import dev.lexip.hecate.R @Composable internal fun ForExpertsSectionCard( - adbCommand: String?, onUseRoot: (() -> Unit)? = null, - onShareExpertCommand: (() -> Unit)? = null, + onShareADBCommand: (() -> Unit)? = null, ) { val haptic = LocalHapticFeedback.current var expanded by remember { mutableStateOf(false) } @@ -91,19 +88,6 @@ internal fun ForExpertsSectionCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(12.dp)) - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shape = MaterialTheme.shapes.small - ) { - Text( - text = adbCommand ?: "", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(12.dp), - fontWeight = FontWeight.Medium - ) - } - Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -117,14 +101,14 @@ internal fun ForExpertsSectionCard( ) { Text(text = stringResource(id = R.string.permission_wizard_action_use_root)) } - Button( + OutlinedButton( onClick = { haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - onShareExpertCommand?.invoke() + onShareADBCommand?.invoke() }, modifier = Modifier.weight(1f) ) { - Text(text = stringResource(id = R.string.action_share)) + Text(text = stringResource(id = R.string.permission_wizard_action_adb_command)) } } } diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt index 0bd77ef9..ad03965f 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt @@ -87,9 +87,8 @@ internal fun GrantPermissionStep( ) ForExpertsSectionCard( - adbCommand = adbCommand, onUseRoot = onUseRoot, - onShareExpertCommand = onShareExpertCommand + onShareADBCommand = onShareExpertCommand ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bec3ff33..cfff7dde 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,6 +2,7 @@ Adaptive Theme lexip.dev/setup Star on GitHub + ADB-Command Cancel Close From 0efc6e4044890b36f2c3228445871cd2bca7040f Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 10:04:44 +0100 Subject: [PATCH 10/23] feat(setup): Add shake animation for the setup required card * And also outsource the card to a dedicated component --- .../lexip/hecate/ui/AdaptiveThemeScreen.kt | 73 +++++++----------- .../lexip/hecate/ui/AdaptiveThemeViewModel.kt | 8 +- .../hecate/ui/components/SetupRequiredCard.kt | 75 +++++++++++++++++++ 3 files changed, 105 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt index 229c1396..296f1a58 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -55,6 +56,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import dev.lexip.hecate.R import dev.lexip.hecate.data.AdaptiveThreshold import dev.lexip.hecate.ui.components.MainSwitchPreferenceCard +import dev.lexip.hecate.ui.components.SetupRequiredCard import dev.lexip.hecate.ui.components.ThreeDotMenu import dev.lexip.hecate.ui.components.preferences.CustomThresholdDialog import dev.lexip.hecate.ui.components.preferences.ProgressDetailCard @@ -101,6 +103,7 @@ fun AdaptiveThemeScreen( } val showCustomDialog = remember { mutableStateOf(false) } + val setupShakeKey = remember { mutableIntStateOf(0) } LaunchedEffect(adaptiveThemeViewModel) { adaptiveThemeViewModel.uiEvents.collect { event -> @@ -176,40 +179,16 @@ fun AdaptiveThemeScreen( // Setup card shown when the required permission has not been granted yet if (!hasWriteSecureSettingsPermission) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface + SetupRequiredCard( + title = stringResource(id = R.string.setup_required_title), + message = stringResource( + id = R.string.setup_required_message, + stringResource(id = R.string.app_name) ), - shape = RoundedCornerShape(24.dp) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.setup_required_title), - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.padding(top = 4.dp)) - Text( - text = stringResource( - id = R.string.setup_required_message, - stringResource(id = R.string.app_name) - ), - style = MaterialTheme.typography.bodyMedium - ) - Spacer(modifier = Modifier.padding(top = 12.dp)) - androidx.compose.foundation.layout.Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - androidx.compose.material3.Button(onClick = { - adaptiveThemeViewModel.onSetupRequested(packageName) - }) { - Text(text = stringResource(id = R.string.action_finish_setup)) - } - } - } - } + onFinishSetupRequested = { adaptiveThemeViewModel.onSetupRequested(packageName) }, + shakeKey = setupShakeKey.value, + modifier = Modifier.fillMaxWidth() + ) } MainSwitchPreferenceCard( @@ -219,17 +198,23 @@ fun AdaptiveThemeScreen( ), isChecked = uiState.adaptiveThemeEnabled, onCheckedChange = { checked -> - adaptiveThemeViewModel.onServiceToggleRequested( - checked, - hasWriteSecureSettingsPermission, - packageName - ).also { wasToggled -> - if (wasToggled) - haptic.performHapticFeedback( - if (checked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff - ) - else - haptic.performHapticFeedback(HapticFeedbackType.Reject) + // Shake animation when user tries to enable service without permission + if (checked && !hasWriteSecureSettingsPermission) { + setupShakeKey.value = setupShakeKey.value + 1 + haptic.performHapticFeedback(HapticFeedbackType.Reject) + } else { + adaptiveThemeViewModel.onServiceToggleRequested( + checked, + hasWriteSecureSettingsPermission, + packageName + ).also { wasToggled -> + if (wasToggled) + haptic.performHapticFeedback( + if (checked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff + ) + else + haptic.performHapticFeedback(HapticFeedbackType.Reject) + } } } diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt index 64430749..d4a8b60e 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -358,13 +358,7 @@ class AdaptiveThemeViewModel( ) } } - - fun clearCustomAdaptiveThemeThreshold() { - viewModelScope.launch { - userPreferencesRepository.clearCustomAdaptiveThemeThreshold() - } - } - + val isUsingCustomThreshold: Boolean get() = _uiState.value.customAdaptiveThemeThresholdLux != null diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt new file mode 100644 index 00000000..65029ffc --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt @@ -0,0 +1,75 @@ +package dev.lexip.hecate.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.lexip.hecate.R + +@Composable +fun SetupRequiredCard( + title: String, + message: String, + onFinishSetupRequested: () -> Unit, + shakeKey: Int = 0, + modifier: Modifier = Modifier.Companion +) { + // Shake animation when user tries to enable service without permission + val offsetAnim = remember { Animatable(0f) } + + LaunchedEffect(shakeKey) { + if (shakeKey > 0) { + val offsets = listOf(-12f, 12f, -8f, 8f, -4f, 4f, 0f) + for (o in offsets) { + offsetAnim.animateTo(o, animationSpec = tween(durationMillis = 45)) + } + } + } + + Card( + modifier = modifier.offset(x = offsetAnim.value.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurface + ), + shape = RoundedCornerShape(24.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.padding(top = 4.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.padding(top = 12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Button(onClick = onFinishSetupRequested) { + Text(text = stringResource(id = R.string.action_finish_setup)) + } + } + } + } +} \ No newline at end of file From c6881c74b2a528f581d2ac8f834c25984d4120d6 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 10:32:44 +0100 Subject: [PATCH 11/23] feat(setup): Show USB pending card in step 3 if not connected * E.g. when step 2 was skipped --- .../lexip/hecate/ui/setup/steps/GrantPermissionStep.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt index ad03965f..76ed6070 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt @@ -54,10 +54,12 @@ internal fun GrantPermissionStep( onCheckPermission: () -> Unit, onExit: () -> Unit, onUseRoot: (() -> Unit)? = null, + isUsbConnected: Boolean, ) { val haptic = LocalHapticFeedback.current val pulseScale = rememberPulseScale(isActive = !hasWriteSecureSettings) + val usbPulseScale = rememberPulseScale(isActive = !isUsbConnected) Column(modifier = Modifier.fillMaxSize()) { Column( @@ -81,6 +83,14 @@ internal fun GrantPermissionStep( SetupWebsiteCard(onShareSetupUrl = onShareSetupUrl) + // Show USB not connected waiting card if USB is not connected (e.g. step 2 was skipped) + if (!isUsbConnected) { + SetupWaitingCard( + title = stringResource(id = R.string.permission_wizard_usb_not_connected), + pulseScale = usbPulseScale + ) + } + SetupWaitingCard( title = stringResource(id = R.string.permission_wizard_permission_not_granted), pulseScale = pulseScale From 5d6783b46602d38e6d8d9fd43cab458bf465f795 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 10:33:03 +0100 Subject: [PATCH 12/23] feat(setup): Show expert section in step 2 as well --- .../lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt | 7 +++++-- .../dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt index d2cab0b0..5bac2bfe 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt @@ -139,7 +139,9 @@ fun PermissionSetupWizardScreen( isShizukuInstalled = isShizukuInstalled, onGrantViaShizuku = onGrantViaShizuku, onNext = onNext, - onExit = onExit + onExit = onExit, + onShareExpertCommand = onShareExpertCommand, + onUseRoot = onUseRoot ) PermissionWizardStep.GRANT_PERMISSION -> GrantPermissionStep( @@ -152,7 +154,8 @@ fun PermissionSetupWizardScreen( onShareExpertCommand = onShareExpertCommand, onCheckPermission = onCheckPermission, onExit = onExit, - onUseRoot = onUseRoot + onUseRoot = onUseRoot, + isUsbConnected = isUsbConnected ) } } diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt index 1457409f..ca755434 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.lexip.hecate.R +import dev.lexip.hecate.ui.setup.components.ForExpertsSectionCard import dev.lexip.hecate.ui.setup.components.SetupFAQCards import dev.lexip.hecate.ui.setup.components.SetupWaitingCard import dev.lexip.hecate.ui.setup.components.ShizukuOptionCard @@ -41,6 +42,8 @@ internal fun ConnectUsbStep( onGrantViaShizuku: () -> Unit, onNext: () -> Unit, onExit: () -> Unit, + onShareExpertCommand: (() -> Unit)? = null, + onUseRoot: (() -> Unit)? = null, ) { val haptic = LocalHapticFeedback.current @@ -89,6 +92,11 @@ internal fun ConnectUsbStep( ) SetupFAQCards() + + ForExpertsSectionCard( + onUseRoot = onUseRoot, + onShareADBCommand = onShareExpertCommand + ) } StepNavigationRow( From e8e5aeace025b2dab7c3781a88377af6fac9a702 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 10:36:23 +0100 Subject: [PATCH 13/23] feat(setup): Collapse FAQ cards by default to save space --- .../hecate/ui/setup/components/FaqCards.kt | 98 ++++++++++++++----- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt index 111b9afe..18855084 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt @@ -12,16 +12,28 @@ package dev.lexip.hecate.ui.setup.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -36,6 +48,8 @@ internal fun SetupFAQCards() { @Composable internal fun WhyOtherDeviceCard() { + var expanded by remember { mutableStateOf(false) } + Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -43,24 +57,43 @@ internal fun WhyOtherDeviceCard() { ) ) { Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.permission_wizard_why_other_device_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_why_other_device), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + ) { + Text( + text = stringResource(id = R.string.permission_wizard_why_other_device_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = null + ) + } + } + + if (expanded) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.permission_wizard_why_other_device), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } @Composable internal fun IsThisSafeCard() { + var expanded by remember { mutableStateOf(false) } + Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -68,18 +101,35 @@ internal fun IsThisSafeCard() { ) ) { Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.permission_wizard_is_this_safe_title), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(id = R.string.permission_wizard_is_this_safe_body), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + ) { + Text( + text = stringResource(id = R.string.permission_wizard_is_this_safe_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = null + ) + } + } + + if (expanded) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.permission_wizard_is_this_safe_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cfff7dde..26969838 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ Adaptive Theme lexip.dev/setup Star on GitHub - ADB-Command + ADB Command Cancel Close From 759fbcf514fec2ce4c8590d422a482f58c7e4eb5 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 10:47:33 +0100 Subject: [PATCH 14/23] refactor(setup): Minor code optimizations and lint fixes --- .../lexip/hecate/analytics/AnalyticsLogger.kt | 2 +- .../lexip/hecate/ui/AdaptiveThemeViewModel.kt | 4 +- .../preferences/ProgressDetailCard.kt | 8 +- .../hecate/ui/setup/PermissionSetupHost.kt | 9 +- .../ui/setup/PermissionSetupWizardScreen.kt | 11 +- .../ui/setup/steps/DeveloperModeStep.kt | 188 ++++++++---------- .../ui/setup/steps/GrantPermissionStep.kt | 4 - app/src/main/res/values-de/strings.xml | 10 +- app/src/main/res/values/strings.xml | 10 +- 9 files changed, 102 insertions(+), 144 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt index c3bf1d4a..6e6f24d4 100644 --- a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt +++ b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt @@ -125,7 +125,7 @@ object AnalyticsLogger { } } - fun logSetupFinished(context: Context, source: String? = null) { + fun logSetupComplete(context: Context, source: String? = null) { ifAllowed { analytics(context).logEvent("setup_finished") { if (source != null) param("source", source) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt index d4a8b60e..0cb26c6e 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -298,7 +298,7 @@ class AdaptiveThemeViewModel( ) { viewModelScope.launch { if (permissionWizardCompletionHandled.getAndSet(true)) return@launch - if (source != null) AnalyticsLogger.logSetupFinished(context, source) + if (source != null) AnalyticsLogger.logSetupComplete(context, source) userPreferencesRepository.updatePermissionWizardCompleted(true) dismissPermissionWizard() updateAdaptiveThemeEnabled(true) @@ -358,7 +358,7 @@ class AdaptiveThemeViewModel( ) } } - + val isUsingCustomThreshold: Boolean get() = _uiState.value.customAdaptiveThemeThresholdLux != null diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt index b73a6e8c..5fbbccc3 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/components/preferences/ProgressDetailCard.kt @@ -52,7 +52,7 @@ fun ProgressDetailCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) - .padding(top = 8.dp, bottom = 8.dp), + .padding(top = 12.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { val segments = (luxSteps.size - 1).coerceAtLeast(1) @@ -73,8 +73,10 @@ fun ProgressDetailCard( // Live lux measurement if (enabled) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = stringResource(id = R.string.title_live_measurement), diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt index c473b68b..bc043049 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt @@ -173,11 +173,11 @@ fun PermissionSetupHost( // If permission becomes granted, auto-complete wizard and enable service if (hasPermission) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) viewModel.completePermissionWizard( context, if (isUsbConnected) "usb" else null ) + haptic.performHapticFeedback(HapticFeedbackType.Confirm) break } @@ -222,8 +222,7 @@ fun PermissionSetupHost( context, if (isUsbConnected) "usb" else null ) - } else { - viewModel.goToNextPermissionWizardStep() + haptic.performHapticFeedback(HapticFeedbackType.Confirm) } } } @@ -261,13 +260,13 @@ fun PermissionSetupHost( context, Manifest.permission.WRITE_SECURE_SETTINGS ) == PackageManager.PERMISSION_GRANTED viewModel.recheckWriteSecureSettingsPermission(nowGranted) + if (nowGranted) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - if (isUsbConnected) AnalyticsLogger.logSetupFinished(context, "usb") viewModel.completePermissionWizard( context, if (isUsbConnected) "usb" else null ) + haptic.performHapticFeedback(HapticFeedbackType.Confirm) } }, onUseRoot = { diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt index 5bac2bfe..c28fafe1 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import dev.lexip.hecate.R import dev.lexip.hecate.ui.setup.steps.ConnectUsbStep +import dev.lexip.hecate.ui.setup.steps.DeveloperModeActions import dev.lexip.hecate.ui.setup.steps.DeveloperModeStep import dev.lexip.hecate.ui.setup.steps.GrantPermissionStep @@ -130,8 +131,10 @@ fun PermissionSetupWizardScreen( onGrantViaShizuku = onGrantViaShizuku, onNext = onNext, onExit = onExit, - onOpenSettings = onOpenSettings, - onOpenDeveloperSettings = onOpenDeveloperSettings + actions = DeveloperModeActions( + onOpenSettings = onOpenSettings, + onOpenDeveloperSettings = onOpenDeveloperSettings + ) ) PermissionWizardStep.CONNECT_USB -> ConnectUsbStep( @@ -145,11 +148,7 @@ fun PermissionSetupWizardScreen( ) PermissionWizardStep.GRANT_PERMISSION -> GrantPermissionStep( - adbCommand = adbCommand, hasWriteSecureSettings = hasWriteSecureSettings, - isShizukuInstalled = isShizukuInstalled, - onGrantViaShizuku = onGrantViaShizuku, - onCopyAdbCommand = onCopyAdbCommand, onShareSetupUrl = onShareSetupUrl, onShareExpertCommand = onShareExpertCommand, onCheckPermission = onCheckPermission, diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt index 5a1585ab..386fc4de 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt @@ -47,6 +47,11 @@ import dev.lexip.hecate.R import dev.lexip.hecate.ui.setup.components.ShizukuOptionCard import dev.lexip.hecate.ui.setup.components.StepNavigationRow +data class DeveloperModeActions( + val onOpenSettings: () -> Unit, + val onOpenDeveloperSettings: () -> Unit, +) + @Composable internal fun DeveloperModeStep( isDeveloperOptionsEnabled: Boolean, @@ -55,21 +60,17 @@ internal fun DeveloperModeStep( onGrantViaShizuku: () -> Unit, onNext: () -> Unit, onExit: () -> Unit, - onOpenSettings: () -> Unit, - onOpenDeveloperSettings: () -> Unit, + actions: DeveloperModeActions, ) { val haptic = LocalHapticFeedback.current val bothEnabled = isDeveloperOptionsEnabled && isUsbDebuggingEnabled - LaunchedEffect(isDeveloperOptionsEnabled) { + LaunchedEffect(isDeveloperOptionsEnabled, isUsbDebuggingEnabled) { if (isDeveloperOptionsEnabled) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + haptic.performHapticFeedback(HapticFeedbackType.Confirm) } - } - - LaunchedEffect(isUsbDebuggingEnabled) { if (isUsbDebuggingEnabled) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + haptic.performHapticFeedback(HapticFeedbackType.Confirm) } } @@ -101,13 +102,13 @@ internal fun DeveloperModeStep( DeveloperOptionsCard( isEnabled = isDeveloperOptionsEnabled, - onOpenSettings = onOpenSettings + onOpenSettings = actions.onOpenSettings ) UsbDebuggingCard( isEnabled = isUsbDebuggingEnabled, isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, - onOpenDeveloperSettings = onOpenDeveloperSettings + onOpenDeveloperSettings = actions.onOpenDeveloperSettings ) ShizukuOptionCard( @@ -131,9 +132,14 @@ internal fun DeveloperModeStep( } @Composable -private fun DeveloperOptionsCard( +private fun StatusCard( isEnabled: Boolean, - onOpenSettings: () -> Unit, + titleResIfEnabled: Int, + titleResIfDisabled: Int, + showAction: Boolean, + actionLabelRes: Int, + actionToastRes: Int, + onAction: () -> Unit, ) { val context = LocalContext.current @@ -147,41 +153,14 @@ private fun DeveloperOptionsCard( ) ) { Column(modifier = Modifier.padding(20.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - if (isEnabled) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(16.dp)) - } - Text( - text = stringResource( - id = if (isEnabled) - R.string.permission_wizard_developer_options_enabled - else - R.string.permission_wizard_developer_options_title - ), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = if (isEnabled) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - } - if (!isEnabled) { + StatusCardHeader(isEnabled, titleResIfEnabled, titleResIfDisabled) + + if (showAction) { Spacer(modifier = Modifier.height(12.dp)) - val toastText = stringResource(R.string.permission_wizard_dev_options_toast) + val toastText = stringResource(actionToastRes) Button( onClick = { - onOpenSettings() + onAction() Toast.makeText( context, toastText, @@ -190,7 +169,7 @@ private fun DeveloperOptionsCard( }, modifier = Modifier.fillMaxWidth() ) { - Text(text = stringResource(id = R.string.permission_wizard_action_open_settings)) + Text(text = stringResource(id = actionLabelRes)) } } } @@ -198,69 +177,68 @@ private fun DeveloperOptionsCard( } @Composable -private fun UsbDebuggingCard( +private fun StatusCardHeader( isEnabled: Boolean, - isDeveloperOptionsEnabled: Boolean, - onOpenDeveloperSettings: () -> Unit, + titleResIfEnabled: Int, + titleResIfDisabled: Int, ) { - val context = LocalContext.current - val usbDebuggingToastText = stringResource(R.string.permission_wizard_usb_debugging_toast) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = if (isEnabled) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surface - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() ) { - Column(modifier = Modifier.padding(20.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - if (isEnabled) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(16.dp)) - } - Text( - text = stringResource( - id = if (isEnabled) - R.string.permission_wizard_usb_debugging_enabled - else - R.string.permission_wizard_usb_debugging_disabled - ), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = if (isEnabled) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - } - if (!isEnabled && isDeveloperOptionsEnabled) { - Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = { - onOpenDeveloperSettings() - Toast.makeText( - context, - usbDebuggingToastText, - Toast.LENGTH_LONG - ).show() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.permission_wizard_action_open_developer_settings)) - } - } + if (isEnabled) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(16.dp)) } + Text( + text = stringResource( + id = if (isEnabled) titleResIfEnabled else titleResIfDisabled + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isEnabled) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) } } + +@Composable +private fun DeveloperOptionsCard( + isEnabled: Boolean, + onOpenSettings: () -> Unit, +) { + StatusCard( + isEnabled = isEnabled, + titleResIfEnabled = R.string.permission_wizard_developer_options_enabled, + titleResIfDisabled = R.string.permission_wizard_developer_options_title, + showAction = !isEnabled, + actionLabelRes = R.string.permission_wizard_action_open_settings, + actionToastRes = R.string.permission_wizard_dev_options_toast, + onAction = onOpenSettings + ) +} + +@Composable +private fun UsbDebuggingCard( + isEnabled: Boolean, + isDeveloperOptionsEnabled: Boolean, + onOpenDeveloperSettings: () -> Unit, +) { + StatusCard( + isEnabled = isEnabled, + titleResIfEnabled = R.string.permission_wizard_usb_debugging_enabled, + titleResIfDisabled = R.string.permission_wizard_usb_debugging_disabled, + showAction = !isEnabled && isDeveloperOptionsEnabled, + actionLabelRes = R.string.permission_wizard_action_open_developer_settings, + actionToastRes = R.string.permission_wizard_usb_debugging_toast, + onAction = onOpenDeveloperSettings + ) +} diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt index 76ed6070..72f14f32 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt @@ -44,11 +44,7 @@ import dev.lexip.hecate.ui.setup.components.rememberPulseScale @Composable internal fun GrantPermissionStep( - adbCommand: String, hasWriteSecureSettings: Boolean, - isShizukuInstalled: Boolean, - onGrantViaShizuku: () -> Unit, - onCopyAdbCommand: () -> Unit, onShareSetupUrl: () -> Unit, onShareExpertCommand: () -> Unit, onCheckPermission: () -> Unit, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4f54cada..291fc370 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,15 +2,12 @@ Abbrechen Schließen Weiter - Kopieren Benachrichtigung ausblenden Fertig Festlegen - Teilen Link teilen %1$s verwenden Überspringen - Service pausieren Wechselt automatisch zwischen Hell- und Dunkelmodus basierend auf dem Umgebungslicht. Das spart Akku und verbessert die Lesbarkeit. Das Design ändert sich nur direkt nachdem du den Bildschirm einschaltest und nur, wenn der Sensor nicht verdeckt ist. @@ -51,7 +48,6 @@ Einstellungen öffnen Mit einem anderen Gerät verbinden Um die Berechtigung zu erteilen, benötigst du ein anderes Gerät (Computer, Tablet oder Smartphone). Verbinde dieses Gerät per USB damit. - Command kopiert. Entwicklermodus aktivieren Aktiviere USB-Debugging in den Entwickleroptionen. Entwickleroptionen aktivieren @@ -64,7 +60,6 @@ Berechtigung erteilt. Berechtigung noch nicht erteilt. Bitte schließe die Einrichtung mit dem anderen Gerät ab. Schritt %1$d von %2$d - USB verbunden USB-Debugging aktivieren USB-Debugging aktiviert Aktiviere USB-Debugging @@ -76,7 +71,6 @@ Alternative: Shizuku Wenn du Shizuku bereits verwendest, benötigst du weder ein zweites Gerät noch ADB. Shizuku verwenden - Shizuku erkannt: Du kannst die Berechtigung damit direkt von diesem Gerät aus erteilen. Root verwenden Einrichtung erforderlich @@ -84,13 +78,11 @@ Einrichtung starten Live-Wert - Shizuku-Version nicht unterstützt. Starte Shizuku und versuche es erneut. Aktiviere Adaptive Theme in Shizuku. Zugriff verweigert. Frage Berechtigung an… - Einrichtung abgeschlossen. Shizuku konnte den Command nicht ausführen. Unerwarteter Fehler mit Shizuku. - Root-Zugriff angefragt... (beta) + Root-Zugriff angefragt… (beta) Root-Vergabe fehlgeschlagen. \ 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 26969838..c6a648d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,14 +7,11 @@ Cancel Close Continue - Copy Hide Notification Finish Set - Share Share Link Skip - Pause Service Use %1$s @@ -57,7 +54,6 @@ Open Settings Connect to setup device To grant the permission, you need a secondary device (computer, tablet, or phone). Connect this device to it via USB. - Command copied. Enable Developer Mode Enable USB-Debugging in the developer options. Enable developer options @@ -70,7 +66,6 @@ Permission granted. Permission not yet granted. Please complete the setup with the other device. Step %1$d of %2$d - USB connected Enable USB Debugging USB Debugging enabled Enable USB Debugging @@ -82,20 +77,17 @@ Alternative: Shizuku If you already use Shizuku, you won\'t need a second device or ADB. Use Shizuku instead - Shizuku detected: you can use it to grant the permission directly from this device. Use Root Setup Required %1$s requires a one-time setup to function. Start Setup - Shizuku version not supported. Start Shizuku and try again. Enable Adaptive Theme in Shizuku. Access denied. Requesting permission… - Setup completed. Shizuku could not run the command. Unexpected error while using Shizuku. - Requesting root access... (beta) + Requesting root access… (beta) Root grant failed. \ No newline at end of file From ee79850c645a37a8de5b0b58de7a3cc99ad30291 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 11:38:17 +0100 Subject: [PATCH 15/23] refactor(util): Reduce cognitive complexity of the Shizuku manager --- .../hecate/util/shizuku/ShizukuManager.kt | 144 +++++++++++------- 1 file changed, 85 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt index 308a477d..0d8b8f12 100644 --- a/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt +++ b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt @@ -93,77 +93,103 @@ object ShizukuManager { val monitor = Object() var result: GrantResult = GrantResult.Unexpected(IllegalStateException("No result")) - val component = ComponentName( - "dev.lexip.hecate", - GrantService::class.java.name - ) - val args = Shizuku.UserServiceArgs(component) - .processNameSuffix("shizuku_grant") - - val connection = object : ServiceConnection { - override fun onServiceConnected( - name: ComponentName?, - binder: IBinder? - ) { + val args = createGrantServiceArgs() + val connection = createGrantServiceConnection(args, cmd, monitor) { grantResult -> + result = grantResult + } + + Shizuku.bindUserService(args, connection) + waitForGrantResult(monitor) + + result + } catch (t: Throwable) { + Log.e(TAG, "Grant via Shizuku failed", t) + GrantResult.Unexpected(t) + } + } + + private fun createGrantServiceArgs(): Shizuku.UserServiceArgs { + val component = ComponentName( + "dev.lexip.hecate", + GrantService::class.java.name + ) + return Shizuku.UserServiceArgs(component) + .processNameSuffix("shizuku_grant") + } + + private fun createGrantServiceConnection( + args: Shizuku.UserServiceArgs, + cmd: String, + monitor: Object, + onResult: (GrantResult) -> Unit + ): ServiceConnection { + return object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + binder: IBinder? + ) { + val result = try { if (binder == null) { - result = GrantResult.ServiceNotRunning - synchronized(monitor) { monitor.notifyAll() } - return + GrantResult.ServiceNotRunning + } else { + executeGrantTransaction(binder, cmd) } - + } catch (t: Throwable) { + when (t) { + is SecurityException -> GrantResult.NotAuthorized + else -> GrantResult.Unexpected(t) + } + } finally { try { - val data = Parcel.obtain() - val reply = Parcel.obtain() - try { - data.writeInterfaceToken("dev.lexip.hecate.util.shizuku.GrantService") - data.writeString(cmd) - val transactionCode = Binder.FIRST_CALL_TRANSACTION + 1 - val success = binder.transact(transactionCode, data, reply, 0) - result = if (success) { - val exitCode = reply.readInt() - if (exitCode == 0) GrantResult.Success - else GrantResult.ShellCommandFailed(exitCode) - } else { - GrantResult.ServiceNotRunning - } - } finally { - data.recycle() - reply.recycle() - } + Shizuku.unbindUserService(args, this, true) } catch (t: Throwable) { - result = when (t) { - is SecurityException -> GrantResult.NotAuthorized - else -> GrantResult.Unexpected(t) - } - } finally { - try { - Shizuku.unbindUserService(args, this, true) - } catch (t: Throwable) { - Log.w(TAG, "Error while unbinding Shizuku user service", t) - } - synchronized(monitor) { monitor.notifyAll() } + Log.w(TAG, "Error while unbinding Shizuku user service", t) + } + synchronized(monitor) { + monitor.notifyAll() } } - override fun onServiceDisconnected(name: ComponentName?) { - Log.d(TAG, "GrantService disconnected: $name") - } + onResult(result) } - Shizuku.bindUserService(args, connection) + override fun onServiceDisconnected(name: ComponentName?) { + Log.d(TAG, "GrantService disconnected: $name") + } + } + } - synchronized(monitor) { - try { - monitor.wait(5000) - } catch (t: InterruptedException) { - Log.w(TAG, "Interrupted while waiting for Shizuku user service", t) - } + private fun executeGrantTransaction( + binder: IBinder, + cmd: String + ): GrantResult { + val data = Parcel.obtain() + val reply = Parcel.obtain() + return try { + data.writeInterfaceToken("dev.lexip.hecate.util.shizuku.GrantService") + data.writeString(cmd) + val transactionCode = Binder.FIRST_CALL_TRANSACTION + 1 + val success = binder.transact(transactionCode, data, reply, 0) + if (!success) { + GrantResult.ServiceNotRunning + } else { + val exitCode = reply.readInt() + if (exitCode == 0) GrantResult.Success + else GrantResult.ShellCommandFailed(exitCode) } + } finally { + data.recycle() + reply.recycle() + } + } - result - } catch (t: Throwable) { - Log.e(TAG, "Grant via Shizuku failed", t) - GrantResult.Unexpected(t) + private fun waitForGrantResult(monitor: Object) { + synchronized(monitor) { + try { + monitor.wait(5000) + } catch (t: InterruptedException) { + Log.w(TAG, "Interrupted while waiting for Shizuku user service", t) + } } } } From da4d9ceb555af5a6ff75bb311216ba1bf77a8975 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 11:42:30 +0100 Subject: [PATCH 16/23] fix(res): Hardcode "Star on GitHub" string * Google Play seems to translate this even though it is untranslatable. --- .../main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt | 2 +- app/src/main/res/values/strings.xml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt b/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt index 9125baca..17a5fed2 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt @@ -127,7 +127,7 @@ fun ThreeDotMenu( // 5) Star on GitHub DropdownMenuItem( - text = { Text(text = stringResource(id = R.string.action_star_on_github)) }, + text = { Text(text = "Star on GitHub") }, onClick = { menuExpanded = false AnalyticsLogger.logOverflowMenuItemClicked( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6a648d0..a6b2f788 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,6 @@ Adaptive Theme lexip.dev/setup - Star on GitHub ADB Command Cancel From 074a69f503b4b1a3e3594f2c32ba3cd0dab06057 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 11:40:20 +0100 Subject: [PATCH 17/23] refactor(ui): Address sonar findings --- .../main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt | 6 +++--- .../dev/lexip/hecate/ui/components/SetupRequiredCard.kt | 5 +++-- .../dev/lexip/hecate/ui/setup/PermissionSetupHost.kt | 2 -- .../lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt | 9 ++++++--- .../hecate/ui/setup/components/StepNavigationRow.kt | 4 ++-- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 3 ++- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt index 296f1a58..0da2f8b3 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -180,14 +180,14 @@ fun AdaptiveThemeScreen( // Setup card shown when the required permission has not been granted yet if (!hasWriteSecureSettingsPermission) { SetupRequiredCard( + modifier = Modifier.fillMaxWidth(), title = stringResource(id = R.string.setup_required_title), message = stringResource( id = R.string.setup_required_message, stringResource(id = R.string.app_name) ), onFinishSetupRequested = { adaptiveThemeViewModel.onSetupRequested(packageName) }, - shakeKey = setupShakeKey.value, - modifier = Modifier.fillMaxWidth() + shakeKey = setupShakeKey.intValue, ) } @@ -200,7 +200,7 @@ fun AdaptiveThemeScreen( onCheckedChange = { checked -> // Shake animation when user tries to enable service without permission if (checked && !hasWriteSecureSettingsPermission) { - setupShakeKey.value = setupShakeKey.value + 1 + setupShakeKey.intValue += 1 haptic.performHapticFeedback(HapticFeedbackType.Reject) } else { adaptiveThemeViewModel.onServiceToggleRequested( diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt b/app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt index 65029ffc..4a3a6c67 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt @@ -20,16 +20,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import dev.lexip.hecate.R @Composable fun SetupRequiredCard( + modifier: Modifier = Modifier, title: String, message: String, onFinishSetupRequested: () -> Unit, shakeKey: Int = 0, - modifier: Modifier = Modifier.Companion ) { // Shake animation when user tries to enable service without permission val offsetAnim = remember { Animatable(0f) } @@ -44,7 +45,7 @@ fun SetupRequiredCard( } Card( - modifier = modifier.offset(x = offsetAnim.value.dp), + modifier = modifier.offset { IntOffset(offsetAnim.value.dp.roundToPx(), 0) }, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = MaterialTheme.colorScheme.onSurface diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt index bc043049..7aa98f0e 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt @@ -193,7 +193,6 @@ fun PermissionSetupHost( PermissionSetupWizardScreen( step = internalUiState.permissionWizardStep, - adbCommand = adbCommand, isUsbConnected = isUsbConnected, hasWriteSecureSettings = hasPermission, isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, @@ -250,7 +249,6 @@ fun PermissionSetupHost( AnalyticsLogger.logShareLinkClicked(context, "permission_wizard") context.shareSetupUrl("https://lexip.dev/setup") }, - onCopyAdbCommand = { viewModel.requestCopyAdbCommand() }, onShareExpertCommand = { context.shareSetupUrl(adbCommand) }, diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt index c28fafe1..40ac835f 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupWizardScreen.kt @@ -48,7 +48,6 @@ enum class PermissionWizardStep { @Composable fun PermissionSetupWizardScreen( step: PermissionWizardStep, - adbCommand: String, isUsbConnected: Boolean, hasWriteSecureSettings: Boolean, isDeveloperOptionsEnabled: Boolean, @@ -60,7 +59,6 @@ fun PermissionSetupWizardScreen( onOpenSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit, onShareSetupUrl: () -> Unit, - onCopyAdbCommand: () -> Unit, onShareExpertCommand: () -> Unit, onCheckPermission: () -> Unit, onUseRoot: () -> Unit, @@ -73,7 +71,12 @@ fun PermissionSetupWizardScreen( containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { TopAppBar( - title = { Text(text = "Service Setup", fontWeight = FontWeight.Bold) }, + title = { + Text( + text = stringResource(id = R.string.permission_wizard_title), + fontWeight = FontWeight.Bold + ) + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, titleContentColor = MaterialTheme.colorScheme.onSurface diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt index 0d2ef529..f3b4b928 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt @@ -28,8 +28,8 @@ internal fun StepNavigationRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp) - .padding(top = 12.dp), + .padding(horizontal = 4.dp) + .padding(top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween ) { OutlinedButton(onClick = onLeft) { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 291fc370..272d4c65 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -44,6 +44,7 @@ Lux-Wert + Service Setup Entwickleroptionen öffnen Einstellungen öffnen Mit einem anderen Gerät verbinden diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6b2f788..9da5abf6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ Lux value + Service Setup Open Developer Options Open Settings Connect to setup device @@ -74,7 +75,7 @@ Is this safe? Yes. It just allows the app to modify settings like the dark mode. This is absolutely safe and completely reversible. Alternative: Shizuku - If you already use Shizuku, you won\'t need a second device or ADB. + If you already use Shizuku, you won’t need a second device or ADB. Use Shizuku instead Use Root From fd9cf7a7f29af9cc8d72baf2793a70b35e89b8dd Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 15:24:25 +0100 Subject: [PATCH 18/23] chore: Tweak min. screen height for top bar collapsing --- app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt index 0da2f8b3..15be12b2 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -78,7 +78,7 @@ fun AdaptiveThemeScreen( val windowInfo = LocalWindowInfo.current val density = LocalDensity.current val screenHeightDp = with(density) { windowInfo.containerSize.height.toDp().value } - val enableCollapsing = screenHeightDp < 700f + val enableCollapsing = screenHeightDp < 650f val scrollBehavior = if (enableCollapsing) { TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) } else null From b0b90eb47add8b04494bce5533f95637e777d343 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 15:23:37 +0100 Subject: [PATCH 19/23] style: Increase three-dot menu color contrast and round corners --- .../hecate/ui/components/ThreeDotMenu.kt | 202 ++++++++++-------- 1 file changed, 110 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt b/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt index 17a5fed2..293bb378 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt @@ -4,20 +4,26 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.provider.Settings import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import dev.lexip.hecate.BuildConfig import dev.lexip.hecate.R @@ -39,132 +45,144 @@ fun ThreeDotMenu( val context = LocalContext.current var menuExpanded by remember { mutableStateOf(false) } - androidx.compose.foundation.layout.Box { + Box { IconButton(onClick = { menuExpanded = true }) { Icon( imageVector = Icons.Filled.MoreVert, contentDescription = stringResource(id = R.string.title_more) ) } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false } - ) { - // 1) Custom Threshold - DropdownMenuItem( - text = { Text(text = stringResource(id = R.string.title_custom_threshold)) }, - enabled = isAdaptiveThemeEnabled, - onClick = { - menuExpanded = false - AnalyticsLogger.logOverflowMenuItemClicked( - context, - "custom_threshold" - ) - if (isAdaptiveThemeEnabled) { - onShowCustomThresholdDialog() - } - } + + MaterialTheme( + shapes = MaterialTheme.shapes.copy( + extraSmall = RoundedCornerShape(14.dp), + small = RoundedCornerShape(14.dp), + medium = RoundedCornerShape(14.dp) ) + ) { + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.clip(MaterialTheme.shapes.extraSmall), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) { - // 2) Change Language (Android 13+) - if (android.os.Build.VERSION.SDK_INT >= 33) { + // 1) Custom Threshold DropdownMenuItem( - text = { Text(text = stringResource(id = R.string.title_change_language)) }, + text = { Text(text = stringResource(id = R.string.title_custom_threshold)) }, + enabled = isAdaptiveThemeEnabled, onClick = { menuExpanded = false AnalyticsLogger.logOverflowMenuItemClicked( context, - "change_language" + "custom_threshold" ) - val intent = - Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { - data = "package:$packageName".toUri() - } - context.startActivity(intent) + if (isAdaptiveThemeEnabled) { + onShowCustomThresholdDialog() + } } ) - } - // 3) Send Feedback - DropdownMenuItem( - text = { Text(text = stringResource(id = R.string.title_send_feedback)) }, - onClick = { - menuExpanded = false - AnalyticsLogger.logOverflowMenuItemClicked( - context, - "send_feedback" - ) - val encodedSubject = URLEncoder.encode( - FEEDBACK_SUBJECT, - StandardCharsets.UTF_8.toString() + // 2) Change Language (Android 13+) + if (android.os.Build.VERSION.SDK_INT >= 33) { + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.title_change_language)) }, + onClick = { + menuExpanded = false + AnalyticsLogger.logOverflowMenuItemClicked( + context, + "change_language" + ) + val intent = + Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + data = "package:$packageName".toUri() + } + context.startActivity(intent) + } ) - val feedbackUri = - "https://lexip.dev/hecate/feedback?subject=$encodedSubject".toUri() - val feedbackIntent = Intent(Intent.ACTION_VIEW, feedbackUri) - context.startActivity(feedbackIntent) - } - ) - // 4) Beta Feedback (only on beta builds) - if (BuildConfig.VERSION_NAME.contains("-beta") && AnalyticsGate.isPlayStoreInstall()) { + // 3) Send Feedback DropdownMenuItem( - text = { Text(text = "Beta Feedback") }, + text = { Text(text = stringResource(id = R.string.title_send_feedback)) }, onClick = { menuExpanded = false AnalyticsLogger.logOverflowMenuItemClicked( context, - "beta_feedback" + "send_feedback" ) - val betaUri = - "https://play.google.com/store/apps/details?id=dev.lexip.hecate".toUri() - val betaIntent = Intent(Intent.ACTION_VIEW, betaUri) - context.startActivity(betaIntent) + val encodedSubject = URLEncoder.encode( + FEEDBACK_SUBJECT, + StandardCharsets.UTF_8.toString() + ) + val feedbackUri = + "https://lexip.dev/hecate/feedback?subject=$encodedSubject".toUri() + val feedbackIntent = Intent(Intent.ACTION_VIEW, feedbackUri) + context.startActivity(feedbackIntent) + } ) - } - // 5) Star on GitHub - DropdownMenuItem( - text = { Text(text = "Star on GitHub") }, - onClick = { - menuExpanded = false - AnalyticsLogger.logOverflowMenuItemClicked( - context, - "star_github" + // 4) Beta Feedback (only on beta builds) + if (BuildConfig.VERSION_NAME.contains("-beta") && AnalyticsGate.isPlayStoreInstall()) { + DropdownMenuItem( + text = { Text(text = "Beta Feedback") }, + onClick = { + menuExpanded = false + AnalyticsLogger.logOverflowMenuItemClicked( + context, + "beta_feedback" + ) + val betaUri = + "https://play.google.com/store/apps/details?id=dev.lexip.hecate".toUri() + val betaIntent = Intent(Intent.ACTION_VIEW, betaUri) + context.startActivity(betaIntent) + } ) - val starUri = "https://lexip.dev/hecate/source".toUri() - val starIntent = Intent(Intent.ACTION_VIEW, starUri) - try { - context.startActivity(starIntent) - } catch (_: ActivityNotFoundException) { - context.startActivity(Intent(Intent.ACTION_VIEW, starUri)) - } } - ) - // 6) About - DropdownMenuItem( - text = { Text(stringResource(R.string.title_about)) }, - onClick = { - menuExpanded = false - AnalyticsLogger.logOverflowMenuItemClicked(context, "about") - val aboutUri = "https://lexip.dev/hecate/about".toUri() - val aboutIntent = Intent(Intent.ACTION_VIEW, aboutUri) - Toast.makeText( - context, - "v${BuildConfig.VERSION_NAME}", - Toast.LENGTH_SHORT - ).show() - try { - context.startActivity(aboutIntent) - } catch (_: ActivityNotFoundException) { - context.startActivity(Intent(Intent.ACTION_VIEW, aboutUri)) + // 5) Star on GitHub + DropdownMenuItem( + text = { Text(text = "Star on GitHub") }, + onClick = { + menuExpanded = false + AnalyticsLogger.logOverflowMenuItemClicked( + context, + "star_github" + ) + val starUri = "https://lexip.dev/hecate/source".toUri() + val starIntent = Intent(Intent.ACTION_VIEW, starUri) + try { + context.startActivity(starIntent) + } catch (_: ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, starUri)) + } + } + ) + + // 6) About + DropdownMenuItem( + text = { Text(stringResource(R.string.title_about)) }, + onClick = { + menuExpanded = false + AnalyticsLogger.logOverflowMenuItemClicked(context, "about") + val aboutUri = "https://lexip.dev/hecate/about".toUri() + val aboutIntent = Intent(Intent.ACTION_VIEW, aboutUri) + Toast.makeText( + context, + "v${BuildConfig.VERSION_NAME}", + Toast.LENGTH_SHORT + ).show() + try { + context.startActivity(aboutIntent) + } catch (_: ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, aboutUri)) + } + onAboutClick() } - onAboutClick() - } - ) + ) + } } } } From 1eb3724078f7aaa81ce6af8ed3944fcabda86361 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 16:44:16 +0100 Subject: [PATCH 20/23] refactor(ui): Change "Star on GitHub" to "Support the project" * Links to README#support-the-project on GitHub * Add GitHub text to about action --- .../lexip/hecate/ui/components/ThreeDotMenu.kt | 15 ++++++++------- app/src/main/res/values-de/strings.xml | 3 ++- app/src/main/res/values/strings.xml | 3 ++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt b/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt index 293bb378..bf6013ac 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt @@ -142,21 +142,22 @@ fun ThreeDotMenu( ) } - // 5) Star on GitHub + // 5) Support the project DropdownMenuItem( - text = { Text(text = "Star on GitHub") }, + text = { Text(text = stringResource(R.string.title_support_project)) }, onClick = { menuExpanded = false AnalyticsLogger.logOverflowMenuItemClicked( context, - "star_github" + "support_project" ) - val starUri = "https://lexip.dev/hecate/source".toUri() - val starIntent = Intent(Intent.ACTION_VIEW, starUri) + val supportUri = + "https://github.com/xLexip/Adaptive-Theme?tab=readme-ov-file#%EF%B8%8F-support-the-project".toUri() + val supportIntent = Intent(Intent.ACTION_VIEW, supportUri) try { - context.startActivity(starIntent) + context.startActivity(supportIntent) } catch (_: ActivityNotFoundException) { - context.startActivity(Intent(Intent.ACTION_VIEW, starUri)) + context.startActivity(Intent(Intent.ACTION_VIEW, supportUri)) } } ) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 272d4c65..b345c6b0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -25,7 +25,7 @@ Wert darf nicht negativ sein. Wert darf 100.000 nicht überschreiten. - Über + Über (GitHub) Helligkeitsschwelle Sprache ändern Aktuelle Helligkeit @@ -33,6 +33,7 @@ Mehr Hintergrundaktivität Feedback senden + Projekt unterstützen Hell Benutzerdefiniert diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9da5abf6..0f455d45 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,7 +29,7 @@ Value cannot be negative. Value cannot exceed 100,000. - About + About (GitHub) Brightness Threshold Live Value Change Language @@ -38,6 +38,7 @@ More Theme Switching Service Send Feedback + Support the project Bright Custom From 5833af4366e99dc8b114420697493b6620dfddeb Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 19:49:00 +0100 Subject: [PATCH 21/23] fix: Add proguard rules for the Shizuku implementation * Otherwise it won't work after shrinking/obfuscation. --- app/proguard-rules.pro | 10 +++++++++- .../java/dev/lexip/hecate/util/shizuku/GrantService.kt | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb434..44a80df9 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,12 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# --- Shizuku integration --- +# Keep all Shizuku library classes used for binder communication +-keep class moe.shizuku.** { *; } +-keep class rikka.shizuku.** { *; } + +# Keep Shizuku user service implementation and its members +-keep class dev.lexip.hecate.util.shizuku.GrantService { *; } diff --git a/app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt b/app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt index ec7eb495..2999bc2c 100644 --- a/app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt +++ b/app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt @@ -3,9 +3,11 @@ package dev.lexip.hecate.util.shizuku import android.os.Binder import android.os.Parcel import android.util.Log +import androidx.annotation.Keep import java.io.BufferedReader import java.io.InputStreamReader +@Keep class GrantService : Binder() { companion object { From 304b9259851bcd27527963795a600261fbf769e0 Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 20:02:12 +0100 Subject: [PATCH 22/23] chore(analytics): Implement Shizuku logs --- .../lexip/hecate/analytics/AnalyticsLogger.kt | 41 ++++++++++++++ .../lexip/hecate/ui/AdaptiveThemeViewModel.kt | 6 ++- .../hecate/util/shizuku/ShizukuManager.kt | 54 ++++++++++++++++--- 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt index 6e6f24d4..0b40ac72 100644 --- a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt +++ b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt @@ -138,4 +138,45 @@ object AnalyticsLogger { analytics(context).logEvent("in_app_update_installed") { } } } + + fun logUnexpectedShizukuError( + context: Context, + operation: String, + stage: String, + throwable: Throwable, + binderReady: Boolean, + packageName: String? = null + ) { + ifAllowed { + analytics(context).logEvent("shizuku_unexpected_error") { + param("operation", operation) + param("stage", stage) + param("exception_type", throwable.javaClass.simpleName) + param("message", throwable.message ?: "no_message") + param("binder_ready", if (binderReady) 1L else 0L) + if (packageName != null) param("package_name", packageName) + } + } + } + + fun logShizukuGrantResult( + context: Context, + result: dev.lexip.hecate.util.shizuku.ShizukuManager.GrantResult, + packageName: String + ) { + ifAllowed { + analytics(context).logEvent("shizuku_grant_result") { + val (resultType, exitCode) = when (result) { + is dev.lexip.hecate.util.shizuku.ShizukuManager.GrantResult.Success -> "success" to null + is dev.lexip.hecate.util.shizuku.ShizukuManager.GrantResult.ServiceNotRunning -> "service_not_running" to null + is dev.lexip.hecate.util.shizuku.ShizukuManager.GrantResult.NotAuthorized -> "not_authorized" to null + is dev.lexip.hecate.util.shizuku.ShizukuManager.GrantResult.ShellCommandFailed -> "shell_command_failed" to result.exitCode + is dev.lexip.hecate.util.shizuku.ShizukuManager.GrantResult.Unexpected -> "unexpected" to null + } + param("result_type", resultType) + exitCode?.let { param("exit_code", it.toLong()) } + param("package_name", packageName) + } + } + } } diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt index 0cb26c6e..9fddecf8 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -415,7 +415,7 @@ class AdaptiveThemeViewModel( return } - if (!ShizukuManager.hasPermission()) { + if (!ShizukuManager.hasPermission(context)) { Toast.makeText( context, context.getString(R.string.shizuku_request_permission), @@ -426,7 +426,8 @@ class AdaptiveThemeViewModel( } viewModelScope.launch(ioDispatcher) { - val result = ShizukuManager.executeGrantViaShizuku(packageName) + val result = ShizukuManager.executeGrantViaShizuku(context, packageName) + AnalyticsLogger.logShizukuGrantResult(context, result, packageName) withContext(mainDispatcher) { when (result) { is ShizukuManager.GrantResult.Success -> { @@ -469,6 +470,7 @@ class AdaptiveThemeViewModel( Toast.LENGTH_LONG ).show() } + } } } diff --git a/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt index 0d8b8f12..1472d3f0 100644 --- a/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt +++ b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt @@ -8,6 +8,7 @@ import android.os.Binder import android.os.IBinder import android.os.Parcel import android.util.Log +import dev.lexip.hecate.analytics.AnalyticsLogger import rikka.shizuku.Shizuku object ShizukuManager { @@ -45,7 +46,7 @@ object ShizukuManager { fun isBinderReady(): Boolean = binderReady - fun hasPermission(): Boolean { + fun hasPermission(context: Context): Boolean { if (Shizuku.isPreV11()) return false if (!binderReady) return false @@ -53,11 +54,18 @@ object ShizukuManager { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED } catch (t: Throwable) { Log.w(TAG, "Failed to check Shizuku permission", t) + AnalyticsLogger.logUnexpectedShizukuError( + context = context, + operation = "check_permission", + stage = "check_self_permission", + throwable = t, + binderReady = binderReady + ) false } } - fun requestPermission(@Suppress("UNUSED_PARAMETER") context: Context) { + fun requestPermission(context: Context) { if (Shizuku.isPreV11()) { Log.w(TAG, "Ignoring Shizuku.requestPermission on pre-v11") return @@ -82,10 +90,10 @@ object ShizukuManager { fun buildGrantWriteSecureSettingsCommand(packageName: String): String = "pm grant $packageName android.permission.WRITE_SECURE_SETTINGS" - fun executeGrantViaShizuku(packageName: String): GrantResult { + fun executeGrantViaShizuku(context: Context, packageName: String): GrantResult { if (Shizuku.isPreV11()) return GrantResult.ServiceNotRunning if (!binderReady) return GrantResult.ServiceNotRunning - if (!hasPermission()) return GrantResult.NotAuthorized + if (!hasPermission(context)) return GrantResult.NotAuthorized val cmd = buildGrantWriteSecureSettingsCommand(packageName) @@ -94,7 +102,13 @@ object ShizukuManager { var result: GrantResult = GrantResult.Unexpected(IllegalStateException("No result")) val args = createGrantServiceArgs() - val connection = createGrantServiceConnection(args, cmd, monitor) { grantResult -> + val connection = createGrantServiceConnection( + context, + args, + cmd, + monitor, + packageName + ) { grantResult -> result = grantResult } @@ -104,6 +118,14 @@ object ShizukuManager { result } catch (t: Throwable) { Log.e(TAG, "Grant via Shizuku failed", t) + AnalyticsLogger.logUnexpectedShizukuError( + context = context, + operation = "grant_write_secure_settings", + stage = "execute_grant_via_shizuku", + throwable = t, + binderReady = binderReady, + packageName = packageName + ) GrantResult.Unexpected(t) } } @@ -118,9 +140,11 @@ object ShizukuManager { } private fun createGrantServiceConnection( + context: Context, args: Shizuku.UserServiceArgs, cmd: String, monitor: Object, + packageName: String, onResult: (GrantResult) -> Unit ): ServiceConnection { return object : ServiceConnection { @@ -137,13 +161,31 @@ object ShizukuManager { } catch (t: Throwable) { when (t) { is SecurityException -> GrantResult.NotAuthorized - else -> GrantResult.Unexpected(t) + else -> { + AnalyticsLogger.logUnexpectedShizukuError( + context = context, + operation = "grant_write_secure_settings", + stage = "on_service_connected_execute", + throwable = t, + binderReady = binderReady, + packageName = packageName + ) + GrantResult.Unexpected(t) + } } } finally { try { Shizuku.unbindUserService(args, this, true) } catch (t: Throwable) { Log.w(TAG, "Error while unbinding Shizuku user service", t) + AnalyticsLogger.logUnexpectedShizukuError( + context = context, + operation = "grant_write_secure_settings", + stage = "unbind_user_service", + throwable = t, + binderReady = binderReady, + packageName = packageName + ) } synchronized(monitor) { monitor.notifyAll() From 9e57c9a6f04cd40929f6bcf2dc60bcb5fd60869b Mon Sep 17 00:00:00 2001 From: xLexip Date: Tue, 9 Dec 2025 15:08:23 +0100 Subject: [PATCH 23/23] docs: Update README --- README.md | 144 ++++++++++++++++++++++++++++++++++++------- app/build.gradle.kts | 4 +- 2 files changed, 125 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2fdccb25..295fda80 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,144 @@ -[![GooglePlay](https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg)](https://lexip.dev/hecate/play) +# Adaptive Theme: Smart Dark Mode -![feature_graphic](https://i.ibb.co/G38P9b9V/adaptive-theme.jpg) +Adaptive Theme intelligently automates your device's theme settings, switching between **Light and +Dark mode** based on your environment's **ambient light** — not just the time of day. -## Adaptive Theme +Get the readability of Light mode in bright daylight and the eye-comfort of Dark mode in low light. +This allows for a true auto dark mode experience that native Android doesn't offer. -Adaptive Theme intelligently switches your device between Light and Dark mode based on your -environment. +## Closed Beta -Get the readability of Light mode in bright daylight and the comfort of Dark mode in low light — -going easy on your eyes and your battery. +Join this [Google Group](https://groups.google.com/g/apphive-testers) before clicking on +this [Google Play](https://play.google.com/apps/testing/dev.lexip.hecate) link to install the beta. + + +--- + +## 📋 Table of Contents + +- [💡 Why use Adaptive Theme?](#-why-use-adaptive-theme) +- [✨ Key Highlights](#-key-highlights) +- [🛠️ One-Time Setup](#%EF%B8%8F-one-time-setup) +- [⚙️ How it Works](#%EF%B8%8F-how-it-works) +- [✅ Safety](#-safety) +- [❓ FAQ](#-faq) +- [❤️ Support the Project](#%EF%B8%8F-support-the-project) +- [📱 Screenshots](#-screenshots) + +--- + +## 💡 Why use Adaptive Theme? + +Most Android phones only switch themes at sunset or based on a fixed schedule. Adaptive Theme uses +your **light sensor** to switch intelligently, optimizing both **eye comfort** and **battery life**. + +## ✨ Key Highlights + +* 🌤️ **Smart Ambient Detection:** Uses your device's physical light sensor to toggle the system + theme. +* ⚙️ **Full Customization:** Set your specific lux threshold (brightness level) and use the Quick + Settings tile to quickly pause/resume the service. +* 🚀 **Modern & Native:** Built with **Jetpack Compose** and **Material You** for a smooth, + crash-free experience. +* 🔋 **Battery Friendly:** The app is passive. It only checks the sensor when you turn the screen + on — zero battery drain in the background. +* 🔒 **Privacy First:** Open Source, completely free, and no ads at all. +* 🗝️ **No Root Required:** Root access is not required (but is supported as an alternative setup + method). +* 🐱 **Optional Shizuku Support:** One of multiple setup options is + using [Shizuku](https://github.com/RikkaApps/Shizuku). --- -### Highlights +## 🛠️ One-Time Setup + +Android restricts apps from changing system themes by default. To unlock this feature, a specific +permission (`WRITE_SECURE_SETTINGS`) is needed. After installing the app, you can choose any of the +following methods: + +#### Method 1: Web Tool (Recommended) + +Use our browser-based setup tool on a secondary device (Computer, Tablet, or Phone). No code or ADB +installation required (WebADB). +👉 **[lexip.dev/setup](https://lexip.dev/setup)** + +#### Method 2: Shizuku (No PC) -🌤️ **Smart Detection**: Uses your ambient light sensor to switch themes automatically. +If you have **Shizuku** installed and configured (via Wireless Debugging or Root), you can grant the +permission directly within the Adaptive Theme app. -⚙️ **Full Control**: Fully customizable brightness threshold and a Quick Settings tile to -pause/resume the service. +#### Method 3: Root -🔒 **Free & Open**: Free to use, no ads and open source. +If your device is rooted, you can grant the permission with one click inside the app. -🚀 **Native Design**: Modern architecture, built with Jetpack Compose and Material You for a seamless -Android experience. +#### Method 4: Manual ADB -🚫 **No Flickering**: The theme only changes when you turn on screen and the device is uncovered. +If you have ADB installed on your computer, you can run the ADB grant command manually via your +terminal. --- -### One-Time Setup +## ⚙️ How it Works + +**Why didn't the theme change immediately?** + +To prevent unnecessary battery drain and screen flickering, Adaptive Theme obeys the following +rules: + +1. It checks the light sensor only **immediately after the screen turns on**. +2. It verifies that the light sensor is **not covered**. +3. It switches the theme **instantly** before you start interacting with the UI. + +--- + +## ✅ Safety + +The required permission does **not** grant root access or read any user data. It only allows the app +to change settings such as "Dark Mode" in the system settings. This is absolutely safe and +completely reversible by uninstalling the app. + +--- + +## ❓ FAQ + +**1. Does this require Root?** +No. It works on stock devices. However, if you have Root, it can optionally be used to set up the +service faster. + +**2. Does it work with custom skins (MIUI, OneUI)?** +In most cases, yes. It works with any system that respects the native Android Dark Mode +implementation. + +**Support & Feedback:** If Adaptive Theme not work for you or if you have any questions, please +create an Issue or send feedback via the app. + +--- + +## ❤️ Support the Project + +Adaptive Theme is **completely free**, **ad-free**, **open source**, and developed in my free time. + +If you enjoy using the app, there are three simple ways you can support the project: + +⭐ **Star on GitHub:** Give this repository a star to help others find it. + +🌟 **Rate on Google Play:** +A [5-star rating](https://play.google.com/store/apps/details?id=dev.lexip.hecate) +is the best way to boost the ranking. + +☕ **Buy me a Coffee:** If you are feeling generous, you can +also [buy me a coffee](https://buymeacoffee.com/lexip). + +📣 **Spread the Word:** Share the app to help the project grow. + +--- -To toggle the system theme, Android requires the permission `WRITE_SECURE_SETTINGS`. This is safe, -transparent and fully reversible. The app will guide you through the setup process. +**🇩🇪 Made in Germany** – Engineered with precision (and 🥨 🍺). --- -That’s it! Set your preference, and never worry about your light/dark mode again. +## 📱 Screenshots -🇩🇪 Made with 🥨 🍺 in Germany. +[![Adaptive Theme Screenshot](https://i.ibb.co/6cngXDnx/Adaptive-Theme-Screenshot.webp)](https://ibb.co/gbjz4tjp) -[![SonarCloud](https://sonarcloud.io/api/project_badges/quality_gate?project=xLexip_Hecate)](https://sonarcloud.io/summary/new_code?id=xLexip_Hecate) +#### [More Screenshots](https://play.google.com/store/apps/details?id=dev.lexip.hecate) \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4e4ebd20..8e616c81 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "dev.lexip.hecate" minSdk = 31 targetSdk = 36 - versionCode = 36 - versionName = "0.7.0" + versionCode = 46 + versionName = "0.9.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }