diff --git a/README.md b/README.md
index 2fdccb2..295fda8 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,144 @@
-[](https://lexip.dev/hecate/play)
+# Adaptive Theme: Smart Dark Mode
-
+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.
+[](https://ibb.co/gbjz4tjp)
-[](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 d0e6742..8e616c8 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"
}
@@ -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/proguard-rules.pro b/app/proguard-rules.pro
index 481bb43..44a80df 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 181296d..d44083e 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">
+
+
+
"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/services/BroadcastReceiverService.kt b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt
index e159020..1007a78 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)
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 5f0ad66..15be12b 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,12 +56,14 @@ 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
import dev.lexip.hecate.ui.components.preferences.SliderDetailCard
import dev.lexip.hecate.ui.setup.PermissionSetupHost
import dev.lexip.hecate.ui.theme.hecateTopAppBarColors
+import dev.lexip.hecate.util.shizuku.ShizukuAvailability
private val ScreenHorizontalMargin = 20.dp
private val horizontalOffsetPadding = 8.dp
@@ -75,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
@@ -94,7 +97,13 @@ fun AdaptiveThemeScreen(
val internalUiState by adaptiveThemeViewModel.uiState.collectAsState()
+ LaunchedEffect(Unit) {
+ val installed = ShizukuAvailability.isShizukuInstalled(context)
+ adaptiveThemeViewModel.setShizukuInstalled(installed)
+ }
+
val showCustomDialog = remember { mutableStateOf(false) }
+ val setupShakeKey = remember { mutableIntStateOf(0) }
LaunchedEffect(adaptiveThemeViewModel) {
adaptiveThemeViewModel.uiEvents.collect { event ->
@@ -170,40 +179,16 @@ fun AdaptiveThemeScreen(
// Setup card shown when the required permission has not been granted yet
if (!hasWriteSecureSettingsPermission) {
- Card(
+ SetupRequiredCard(
modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
- contentColor = MaterialTheme.colorScheme.onSurface
+ 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.intValue,
+ )
}
MainSwitchPreferenceCard(
@@ -213,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.intValue += 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 5af4e37..9fddecf 100644
--- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt
+++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt
@@ -12,13 +12,17 @@
package dev.lexip.hecate.ui
+import android.content.ActivityNotFoundException
import android.content.Intent
+import android.content.pm.PackageManager
import android.hardware.SensorManager
+import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dev.lexip.hecate.HecateApplication
+import dev.lexip.hecate.R
import dev.lexip.hecate.analytics.AnalyticsLogger
import dev.lexip.hecate.data.AdaptiveThreshold
import dev.lexip.hecate.data.UserPreferencesRepository
@@ -27,6 +31,10 @@ import dev.lexip.hecate.ui.setup.PermissionWizardStep
import dev.lexip.hecate.util.DarkThemeHandler
import dev.lexip.hecate.util.LightSensorManager
import dev.lexip.hecate.util.ProximitySensorManager
+import dev.lexip.hecate.util.shizuku.ShizukuAvailability
+import dev.lexip.hecate.util.shizuku.ShizukuManager
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -34,6 +42,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
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"
sealed interface UiEvent {
data class CopyToClipboard(val text: String) : UiEvent
@@ -48,19 +61,29 @@ data class AdaptiveThemeUiState(
val permissionWizardCompleted: Boolean = false,
val hasAutoAdvancedFromDeveloperMode: Boolean = false,
val hasAutoAdvancedFromConnectUsb: Boolean = false,
- val isDeviceCovered: Boolean = false
+ val isDeviceCovered: Boolean = false,
+ val isShizukuInstalled: Boolean = false
)
class AdaptiveThemeViewModel(
private val application: HecateApplication,
private val userPreferencesRepository: UserPreferencesRepository,
@Suppress("unused")
- private var _darkThemeHandler: DarkThemeHandler
+ private var _darkThemeHandler: DarkThemeHandler,
+ private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
+ private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main
) : ViewModel() {
private val _uiState = MutableStateFlow(AdaptiveThemeUiState())
val uiState: StateFlow = _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
private val _uiEvents = MutableSharedFlow(
replay = 0,
@@ -133,6 +156,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 ->
@@ -150,6 +176,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() {
@@ -169,6 +225,7 @@ class AdaptiveThemeViewModel(
}
override fun onCleared() {
+ registeredShizukuListener?.let { Shizuku.removeRequestPermissionResultListener(it) }
stopLightSensorListening()
stopProximityListening()
super.onCleared()
@@ -234,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.logSetupComplete(context, source)
userPreferencesRepository.updatePermissionWizardCompleted(true)
dismissPermissionWizard()
updateAdaptiveThemeEnabled(true)
@@ -296,12 +359,6 @@ class AdaptiveThemeViewModel(
}
}
- fun clearCustomAdaptiveThemeThreshold() {
- viewModelScope.launch {
- userPreferencesRepository.clearCustomAdaptiveThemeThreshold()
- }
- }
-
val isUsingCustomThreshold: Boolean
get() = _uiState.value.customAdaptiveThemeThresholdLux != null
@@ -344,6 +401,149 @@ 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(context)) {
+ 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(context, packageName)
+ AnalyticsLogger.logShizukuGrantResult(context, result, packageName)
+ withContext(mainDispatcher) {
+ when (result) {
+ is ShizukuManager.GrantResult.Success -> {
+ // Setup using Shizuku complete
+ completePermissionWizard(
+ context,
+ source = "shizuku"
+ )
+ }
+
+ 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),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ is ShizukuManager.GrantResult.Unexpected -> {
+ Toast.makeText(
+ context,
+ context.getString(R.string.shizuku_grant_unexpected),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ }
+ }
+ }
+ }
+
+ 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 {
+ 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/InAppUpdateManager.kt b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt
index 0afed3e..616cc6f 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 1cbefc6..a86dc7e 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
@@ -80,14 +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()
+ if (adaptiveThemeViewModel.isAdaptiveThemeEnabled()) {
+ val intent = android.content.Intent(this, BroadcastReceiverService::class.java)
+ androidx.core.content.ContextCompat.startForegroundService(this, intent)
+ }
}
- inAppUpdateManager?.resumeImmediateUpdateIfNeeded()
}
override fun onPause() {
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 0000000..4a3a6c6
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/components/SetupRequiredCard.kt
@@ -0,0 +1,76 @@
+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.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,
+) {
+ // 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 { IntOffset(offsetAnim.value.dp.roundToPx(), 0) },
+ 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
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 9125bac..bf6013a 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,145 @@ 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 = stringResource(id = R.string.action_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) Support the project
+ DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.title_support_project)) },
+ onClick = {
+ menuExpanded = false
+ AnalyticsLogger.logOverflowMenuItemClicked(
+ context,
+ "support_project"
+ )
+ 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(supportIntent)
+ } catch (_: ActivityNotFoundException) {
+ context.startActivity(Intent(Intent.ACTION_VIEW, supportUri))
+ }
+ }
+ )
+
+ // 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()
- }
- )
+ )
+ }
}
}
}
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 8ff14df..5fbbccc 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(
@@ -46,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)
@@ -63,6 +69,29 @@ fun ProgressDetailCard(
activeIndex = activeIndex,
enabled = enabled
)
+
+ // Live lux measurement
+ if (enabled) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 6.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(id = R.string.title_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/ui/setup/PermissionSetupCommon.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt
deleted file mode 100644
index 561c63b..0000000
--- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupCommon.kt
+++ /dev/null
@@ -1,239 +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.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
-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.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(
- 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,
- )
- }
-
- val pulseScale = remember { Animatable(0.8f) }
-
- LaunchedEffect(isWaiting) {
- if (isWaiting) {
- pulseScale.animateTo(
- targetValue = 1.2f,
- animationSpec = infiniteRepeatable(
- animation = tween(durationMillis = 750, easing = LinearOutSlowInEasing),
- repeatMode = RepeatMode.Reverse
- )
- )
- } else {
- pulseScale.snapTo(1.0f)
- }
- }
-
- Card(
- onClick = onClick ?: {},
- enabled = onClick != null,
- modifier = Modifier
- .fillMaxWidth()
- .height(80.dp),
- colors = cardColors
- ) {
- Row(
- modifier = Modifier
- .fillMaxSize()
- .padding(20.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Row(
- 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
- )
- 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
- )
- }
- }
- }
-}
-
-@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/PermissionSetupHost.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt
index b3568e5..7aa98f0 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,9 +1,22 @@
+/*
+ * 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
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
@@ -16,8 +29,10 @@ 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
@Composable
fun PermissionSetupHost(
@@ -33,6 +48,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)
}
@@ -156,8 +173,11 @@ 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
+ )
+ haptic.performHapticFeedback(HapticFeedbackType.Confirm)
break
}
@@ -173,11 +193,15 @@ fun PermissionSetupHost(
PermissionSetupWizardScreen(
step = internalUiState.permissionWizardStep,
- adbCommand = adbCommand,
isUsbConnected = isUsbConnected,
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,10 +217,11 @@ fun PermissionSetupHost(
PermissionWizardStep.GRANT_PERMISSION -> {
if (hasPermission) {
- AnalyticsLogger.logSetupFinished(context)
- viewModel.completePermissionWizardAndEnableService()
- } else {
- viewModel.goToNextPermissionWizardStep()
+ viewModel.completePermissionWizard(
+ context,
+ if (isUsbConnected) "usb" else null
+ )
+ haptic.performHapticFeedback(HapticFeedbackType.Confirm)
}
}
}
@@ -224,7 +249,6 @@ fun PermissionSetupHost(
AnalyticsLogger.logShareLinkClicked(context, "permission_wizard")
context.shareSetupUrl("https://lexip.dev/setup")
},
- onCopyAdbCommand = { viewModel.requestCopyAdbCommand() },
onShareExpertCommand = {
context.shareSetupUrl(adbCommand)
},
@@ -234,11 +258,22 @@ fun PermissionSetupHost(
context, Manifest.permission.WRITE_SECURE_SETTINGS
) == PackageManager.PERMISSION_GRANTED
viewModel.recheckWriteSecureSettingsPermission(nowGranted)
+
if (nowGranted) {
- haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
- AnalyticsLogger.logSetupFinished(context)
- viewModel.completePermissionWizardAndEnableService()
+ viewModel.completePermissionWizard(
+ context,
+ if (isUsbConnected) "usb" else null
+ )
+ haptic.performHapticFeedback(HapticFeedbackType.Confirm)
}
+ },
+ 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/PermissionSetupSteps.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt
deleted file mode 100644
index 52b60e6..0000000
--- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt
+++ /dev/null
@@ -1,573 +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,
- 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.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,
- 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))
-
- ConnectionWhySection()
-
- Spacer(modifier = Modifier.weight(1f))
- 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,
- 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(0.8f)
- }
- }
-
- LaunchedEffect(hasWriteSecureSettings) {
- if (hasWriteSecureSettings) {
- haptic.performHapticFeedback(HapticFeedbackType.Confirm)
- }
- }
-
- 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.weight(1f))
- Spacer(modifier = Modifier.height(12.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 3961a72..40ac835 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,10 @@ 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.DeveloperModeActions
+import dev.lexip.hecate.ui.setup.steps.DeveloperModeStep
+import dev.lexip.hecate.ui.setup.steps.GrantPermissionStep
enum class PermissionWizardStep {
ENABLE_DEVELOPER_MODE,
@@ -44,19 +48,20 @@ enum class PermissionWizardStep {
@Composable
fun PermissionSetupWizardScreen(
step: PermissionWizardStep,
- adbCommand: String,
isUsbConnected: Boolean,
hasWriteSecureSettings: Boolean,
isDeveloperOptionsEnabled: Boolean,
isUsbDebuggingEnabled: Boolean,
+ isShizukuInstalled: Boolean,
+ onGrantViaShizuku: () -> Unit,
onNext: () -> Unit,
onExit: () -> Unit,
onOpenSettings: () -> Unit,
onOpenDeveloperSettings: () -> Unit,
onShareSetupUrl: () -> Unit,
- onCopyAdbCommand: () -> Unit,
onShareExpertCommand: () -> Unit,
onCheckPermission: () -> Unit,
+ onUseRoot: () -> Unit,
) {
val totalSteps = PermissionWizardStep.entries.size
val currentStepIndex = step.ordinal + 1
@@ -66,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
@@ -120,26 +130,34 @@ fun PermissionSetupWizardScreen(
PermissionWizardStep.ENABLE_DEVELOPER_MODE -> DeveloperModeStep(
isDeveloperOptionsEnabled = isDeveloperOptionsEnabled,
isUsbDebuggingEnabled = isUsbDebuggingEnabled,
+ isShizukuInstalled = isShizukuInstalled,
+ onGrantViaShizuku = onGrantViaShizuku,
onNext = onNext,
onExit = onExit,
- onOpenSettings = onOpenSettings,
- onOpenDeveloperSettings = onOpenDeveloperSettings
+ actions = DeveloperModeActions(
+ onOpenSettings = onOpenSettings,
+ onOpenDeveloperSettings = onOpenDeveloperSettings
+ )
)
PermissionWizardStep.CONNECT_USB -> ConnectUsbStep(
isUsbConnected = isUsbConnected,
+ isShizukuInstalled = isShizukuInstalled,
+ onGrantViaShizuku = onGrantViaShizuku,
onNext = onNext,
- onExit = onExit
+ onExit = onExit,
+ onShareExpertCommand = onShareExpertCommand,
+ onUseRoot = onUseRoot
)
PermissionWizardStep.GRANT_PERMISSION -> GrantPermissionStep(
- adbCommand = adbCommand,
hasWriteSecureSettings = hasWriteSecureSettings,
- onCopyAdbCommand = onCopyAdbCommand,
onShareSetupUrl = onShareSetupUrl,
onShareExpertCommand = onShareExpertCommand,
onCheckPermission = onCheckPermission,
- onExit = onExit
+ onExit = onExit,
+ onUseRoot = onUseRoot,
+ isUsbConnected = isUsbConnected
)
}
}
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 3e5b8e0..36ea871 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 0000000..1885508
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/FaqCards.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.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
+import androidx.compose.ui.unit.dp
+import dev.lexip.hecate.R
+
+@Composable
+internal fun SetupFAQCards() {
+ WhyOtherDeviceCard()
+ IsThisSafeCard()
+}
+
+@Composable
+internal fun WhyOtherDeviceCard() {
+ var expanded by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ 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(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ 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/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt
new file mode 100644
index 0000000..1f07076
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ForExpertsSection.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.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.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.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.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 ForExpertsSectionCard(
+ onUseRoot: (() -> Unit)? = null,
+ onShareADBCommand: (() -> Unit)? = null,
+) {
+ 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.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(8.dp))
+ Text(
+ text = stringResource(id = R.string.permission_wizard_manual_command),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onUseRoot?.invoke()
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(text = stringResource(id = R.string.permission_wizard_action_use_root))
+ }
+ OutlinedButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onShareADBCommand?.invoke()
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(text = stringResource(id = R.string.permission_wizard_action_adb_command))
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/components/ShizukuOptionCard.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ShizukuOptionCard.kt
new file mode 100644
index 0000000..cbe83dd
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/ShizukuOptionCard.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.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.secondaryContainer,
+ )
+ ) {
+ 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.onSecondaryContainer
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(id = R.string.permission_wizard_shizuku_body),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ 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/ui/setup/components/StepNavigationRow.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/StepNavigationRow.kt
new file mode 100644
index 0000000..f3b4b92
--- /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 = 4.dp)
+ .padding(top = 8.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/components/WaitingCard.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/components/WaitingCard.kt
new file mode 100644
index 0000000..4290a95
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/components/WaitingCard.kt
@@ -0,0 +1,121 @@
+/*
+ * 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.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.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Circle
+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.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+@Composable
+internal fun rememberPulseScale(isActive: Boolean): Float {
+ val pulseScale = remember { Animatable(0.8f) }
+
+ LaunchedEffect(isActive) {
+ if (isActive) {
+ pulseScale.animateTo(
+ targetValue = 1.2f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 750, easing = LinearOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ )
+ )
+ } else {
+ pulseScale.snapTo(1.0f)
+ }
+ }
+
+ 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(),
+ colors = cardColors
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.weight(1f)
+ ) {
+ WaitingCircle(
+ pulseScale = pulseScale,
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ 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 0000000..ca75543
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/ConnectUsbStep.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.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
+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,
+ onShareExpertCommand: (() -> Unit)? = null,
+ onUseRoot: (() -> Unit)? = null,
+) {
+ 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()
+
+ ForExpertsSectionCard(
+ onUseRoot = onUseRoot,
+ onShareADBCommand = onShareExpertCommand
+ )
+ }
+
+ 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 0000000..386fc4d
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/DeveloperModeStep.kt
@@ -0,0 +1,244 @@
+/*
+ * 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
+
+data class DeveloperModeActions(
+ val onOpenSettings: () -> Unit,
+ val onOpenDeveloperSettings: () -> Unit,
+)
+
+@Composable
+internal fun DeveloperModeStep(
+ isDeveloperOptionsEnabled: Boolean,
+ isUsbDebuggingEnabled: Boolean,
+ isShizukuInstalled: Boolean,
+ onGrantViaShizuku: () -> Unit,
+ onNext: () -> Unit,
+ onExit: () -> Unit,
+ actions: DeveloperModeActions,
+) {
+ val haptic = LocalHapticFeedback.current
+ val bothEnabled = isDeveloperOptionsEnabled && isUsbDebuggingEnabled
+
+ LaunchedEffect(isDeveloperOptionsEnabled, isUsbDebuggingEnabled) {
+ if (isDeveloperOptionsEnabled) {
+ haptic.performHapticFeedback(HapticFeedbackType.Confirm)
+ }
+ if (isUsbDebuggingEnabled) {
+ haptic.performHapticFeedback(HapticFeedbackType.Confirm)
+ }
+ }
+
+ 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 = actions.onOpenSettings
+ )
+
+ UsbDebuggingCard(
+ isEnabled = isUsbDebuggingEnabled,
+ isDeveloperOptionsEnabled = isDeveloperOptionsEnabled,
+ onOpenDeveloperSettings = actions.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 StatusCard(
+ isEnabled: Boolean,
+ titleResIfEnabled: Int,
+ titleResIfDisabled: Int,
+ showAction: Boolean,
+ actionLabelRes: Int,
+ actionToastRes: Int,
+ onAction: () -> 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)) {
+ StatusCardHeader(isEnabled, titleResIfEnabled, titleResIfDisabled)
+
+ if (showAction) {
+ Spacer(modifier = Modifier.height(12.dp))
+ val toastText = stringResource(actionToastRes)
+ Button(
+ onClick = {
+ onAction()
+ Toast.makeText(
+ context,
+ toastText,
+ Toast.LENGTH_LONG
+ ).show()
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(id = actionLabelRes))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatusCardHeader(
+ isEnabled: Boolean,
+ titleResIfEnabled: Int,
+ titleResIfDisabled: Int,
+) {
+ 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) 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
new file mode 100644
index 0000000..72f14f3
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/ui/setup/steps/GrantPermissionStep.kt
@@ -0,0 +1,153 @@
+/*
+ * 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(
+ hasWriteSecureSettings: Boolean,
+ onShareSetupUrl: () -> Unit,
+ onShareExpertCommand: () -> Unit,
+ 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(
+ 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)
+
+ // 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
+ )
+
+ ForExpertsSectionCard(
+ onUseRoot = onUseRoot,
+ onShareADBCommand = 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
+ )
+ }
+ }
+ }
+}
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 dece9df..3ac4425 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/java/dev/lexip/hecate/util/InstallSourceChecker.kt b/app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt
index 889e93f..7a60f28 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 0000000..2999bc2
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/util/shizuku/GrantService.kt
@@ -0,0 +1,61 @@
+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 {
+ 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 0000000..980dcd4
--- /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 0000000..1472d3f
--- /dev/null
+++ b/app/src/main/java/dev/lexip/hecate/util/shizuku/ShizukuManager.kt
@@ -0,0 +1,237 @@
+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 dev.lexip.hecate.analytics.AnalyticsLogger
+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(context: Context): 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)
+ AnalyticsLogger.logUnexpectedShizukuError(
+ context = context,
+ operation = "check_permission",
+ stage = "check_self_permission",
+ throwable = t,
+ binderReady = binderReady
+ )
+ false
+ }
+ }
+
+ fun requestPermission(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(context: Context, packageName: String): GrantResult {
+ if (Shizuku.isPreV11()) return GrantResult.ServiceNotRunning
+ if (!binderReady) return GrantResult.ServiceNotRunning
+ if (!hasPermission(context)) return GrantResult.NotAuthorized
+
+ val cmd = buildGrantWriteSecureSettingsCommand(packageName)
+
+ return try {
+ val monitor = Object()
+ var result: GrantResult = GrantResult.Unexpected(IllegalStateException("No result"))
+
+ val args = createGrantServiceArgs()
+ val connection = createGrantServiceConnection(
+ context,
+ args,
+ cmd,
+ monitor,
+ packageName
+ ) { grantResult ->
+ result = grantResult
+ }
+
+ Shizuku.bindUserService(args, connection)
+ waitForGrantResult(monitor)
+
+ 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)
+ }
+ }
+
+ 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(
+ context: Context,
+ args: Shizuku.UserServiceArgs,
+ cmd: String,
+ monitor: Object,
+ packageName: String,
+ onResult: (GrantResult) -> Unit
+ ): ServiceConnection {
+ return object : ServiceConnection {
+ override fun onServiceConnected(
+ name: ComponentName?,
+ binder: IBinder?
+ ) {
+ val result = try {
+ if (binder == null) {
+ GrantResult.ServiceNotRunning
+ } else {
+ executeGrantTransaction(binder, cmd)
+ }
+ } catch (t: Throwable) {
+ when (t) {
+ is SecurityException -> GrantResult.NotAuthorized
+ 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()
+ }
+ }
+
+ onResult(result)
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ Log.d(TAG, "GrantService disconnected: $name")
+ }
+ }
+ }
+
+ 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()
+ }
+ }
+
+ 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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 4f9a5e6..b345c6b 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.
@@ -28,7 +25,7 @@
Wert darf nicht negativ sein.
Wert darf 100.000 nicht überschreiten.
- Über
+ Über (GitHub)
Helligkeitsschwelle
Sprache ändern
Aktuelle Helligkeit
@@ -36,6 +33,7 @@
Mehr
Hintergrundaktivität
Feedback senden
+ Projekt unterstützen
Hell
Benutzerdefiniert
@@ -47,34 +45,46 @@
Lux-Wert
+ Service Setup
Entwickleroptionen öffnen
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
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
- USB verbunden
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
+ Root verwenden
Einrichtung erforderlich
%1$s benötigt eine einmalige Einrichtung, um zu funktionieren.
Einrichtung starten
+
+ Live-Wert
+ Starte Shizuku und versuche es erneut.
+ Aktiviere Adaptive Theme in Shizuku. Zugriff verweigert.
+ Frage Berechtigung an…
+ 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 ce28f28..0f455d4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,19 +1,16 @@
Adaptive Theme
lexip.dev/setup
- Star on GitHub
+ ADB Command
Cancel
Close
Continue
- Copy
Hide Notification
Finish
Set
- Share
Share Link
Skip
- Pause Service
Use %1$s
@@ -32,14 +29,16 @@
Value cannot be negative.
Value cannot exceed 100,000.
- About
+ About (GitHub)
Brightness Threshold
+ Live Value
Change Language
Current Brightness
Custom Threshold
More
Theme Switching Service
Send Feedback
+ Support the project
Bright
Custom
@@ -51,34 +50,45 @@
Lux value
+ Service Setup
Open Developer Options
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
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
- USB connected
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
+ Use Root
Setup Required
%1$s requires a one-time setup to function.
Start Setup
+
+ Start Shizuku and try again.
+ Enable Adaptive Theme in Shizuku. Access denied.
+ Requesting permission…
+ Shizuku could not run the command.
+ Unexpected error while using Shizuku.
+ Requesting root access… (beta)
+ Root grant failed.
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1ed0222..e131638 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" }