diff --git a/README.md b/README.md index 2fdccb2..295fda8 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,144 @@ -[![GooglePlay](https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg)](https://lexip.dev/hecate/play) +# Adaptive Theme: Smart Dark Mode -![feature_graphic](https://i.ibb.co/G38P9b9V/adaptive-theme.jpg) +Adaptive Theme intelligently automates your device's theme settings, switching between **Light and +Dark mode** based on your environment's **ambient light** — not just the time of day. -## Adaptive Theme +Get the readability of Light mode in bright daylight and the eye-comfort of Dark mode in low light. +This allows for a true auto dark mode experience that native Android doesn't offer. -Adaptive Theme intelligently switches your device between Light and Dark mode based on your -environment. +## Closed Beta -Get the readability of Light mode in bright daylight and the comfort of Dark mode in low light — -going easy on your eyes and your battery. +Join this [Google Group](https://groups.google.com/g/apphive-testers) before clicking on +this [Google Play](https://play.google.com/apps/testing/dev.lexip.hecate) link to install the beta. + + +--- + +## 📋 Table of Contents + +- [💡 Why use Adaptive Theme?](#-why-use-adaptive-theme) +- [✨ Key Highlights](#-key-highlights) +- [🛠️ One-Time Setup](#%EF%B8%8F-one-time-setup) +- [⚙️ How it Works](#%EF%B8%8F-how-it-works) +- [✅ Safety](#-safety) +- [❓ FAQ](#-faq) +- [❤️ Support the Project](#%EF%B8%8F-support-the-project) +- [📱 Screenshots](#-screenshots) + +--- + +## 💡 Why use Adaptive Theme? + +Most Android phones only switch themes at sunset or based on a fixed schedule. Adaptive Theme uses +your **light sensor** to switch intelligently, optimizing both **eye comfort** and **battery life**. + +## ✨ Key Highlights + +* 🌤️ **Smart Ambient Detection:** Uses your device's physical light sensor to toggle the system + theme. +* ⚙️ **Full Customization:** Set your specific lux threshold (brightness level) and use the Quick + Settings tile to quickly pause/resume the service. +* 🚀 **Modern & Native:** Built with **Jetpack Compose** and **Material You** for a smooth, + crash-free experience. +* 🔋 **Battery Friendly:** The app is passive. It only checks the sensor when you turn the screen + on — zero battery drain in the background. +* 🔒 **Privacy First:** Open Source, completely free, and no ads at all. +* 🗝️ **No Root Required:** Root access is not required (but is supported as an alternative setup + method). +* 🐱 **Optional Shizuku Support:** One of multiple setup options is + using [Shizuku](https://github.com/RikkaApps/Shizuku). --- -### Highlights +## 🛠️ One-Time Setup + +Android restricts apps from changing system themes by default. To unlock this feature, a specific +permission (`WRITE_SECURE_SETTINGS`) is needed. After installing the app, you can choose any of the +following methods: + +#### Method 1: Web Tool (Recommended) + +Use our browser-based setup tool on a secondary device (Computer, Tablet, or Phone). No code or ADB +installation required (WebADB). +👉 **[lexip.dev/setup](https://lexip.dev/setup)** + +#### Method 2: Shizuku (No PC) -🌤️ **Smart Detection**: Uses your ambient light sensor to switch themes automatically. +If you have **Shizuku** installed and configured (via Wireless Debugging or Root), you can grant the +permission directly within the Adaptive Theme app. -⚙️ **Full Control**: Fully customizable brightness threshold and a Quick Settings tile to -pause/resume the service. +#### Method 3: Root -🔒 **Free & Open**: Free to use, no ads and open source. +If your device is rooted, you can grant the permission with one click inside the app. -🚀 **Native Design**: Modern architecture, built with Jetpack Compose and Material You for a seamless -Android experience. +#### Method 4: Manual ADB -🚫 **No Flickering**: The theme only changes when you turn on screen and the device is uncovered. +If you have ADB installed on your computer, you can run the ADB grant command manually via your +terminal. --- -### One-Time Setup +## ⚙️ How it Works + +**Why didn't the theme change immediately?** + +To prevent unnecessary battery drain and screen flickering, Adaptive Theme obeys the following +rules: + +1. It checks the light sensor only **immediately after the screen turns on**. +2. It verifies that the light sensor is **not covered**. +3. It switches the theme **instantly** before you start interacting with the UI. + +--- + +## ✅ Safety + +The required permission does **not** grant root access or read any user data. It only allows the app +to change settings such as "Dark Mode" in the system settings. This is absolutely safe and +completely reversible by uninstalling the app. + +--- + +## ❓ FAQ + +**1. Does this require Root?** +No. It works on stock devices. However, if you have Root, it can optionally be used to set up the +service faster. + +**2. Does it work with custom skins (MIUI, OneUI)?** +In most cases, yes. It works with any system that respects the native Android Dark Mode +implementation. + +**Support & Feedback:** If Adaptive Theme not work for you or if you have any questions, please +create an Issue or send feedback via the app. + +--- + +## ❤️ Support the Project + +Adaptive Theme is **completely free**, **ad-free**, **open source**, and developed in my free time. + +If you enjoy using the app, there are three simple ways you can support the project: + +⭐ **Star on GitHub:** Give this repository a star to help others find it. + +🌟 **Rate on Google Play:** +A [5-star rating](https://play.google.com/store/apps/details?id=dev.lexip.hecate) +is the best way to boost the ranking. + +☕ **Buy me a Coffee:** If you are feeling generous, you can +also [buy me a coffee](https://buymeacoffee.com/lexip). + +📣 **Spread the Word:** Share the app to help the project grow. + +--- -To toggle the system theme, Android requires the permission `WRITE_SECURE_SETTINGS`. This is safe, -transparent and fully reversible. The app will guide you through the setup process. +**🇩🇪 Made in Germany** – Engineered with precision (and 🥨 🍺). --- -That’s it! Set your preference, and never worry about your light/dark mode again. +## 📱 Screenshots -🇩🇪 Made with 🥨 🍺 in Germany. +[![Adaptive Theme Screenshot](https://i.ibb.co/6cngXDnx/Adaptive-Theme-Screenshot.webp)](https://ibb.co/gbjz4tjp) -[![SonarCloud](https://sonarcloud.io/api/project_badges/quality_gate?project=xLexip_Hecate)](https://sonarcloud.io/summary/new_code?id=xLexip_Hecate) +#### [More Screenshots](https://play.google.com/store/apps/details?id=dev.lexip.hecate) \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 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" }