From f1fab32cba60cca250cdda0be061f46f7186760b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 18:59:03 +0000 Subject: [PATCH 01/23] chore(deps): update actions/checkout action to v6 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0dfd5e..5d52335 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ jobs: contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis From 9e727187b6c38575b690ef04d5af6c95484c7eda Mon Sep 17 00:00:00 2001 From: xLexip Date: Fri, 5 Dec 2025 21:36:01 +0100 Subject: [PATCH 02/23] refactor: Polish setup code and add analytics --- .../lexip/hecate/analytics/AnalyticsLogger.kt | 28 ++ .../lexip/hecate/ui/AdaptiveThemeScreen.kt | 214 +-------------- .../hecate/ui/setup/PermissionSetupHost.kt | 244 ++++++++++++++++++ .../lexip/hecate/ui/setup/ShareExtensions.kt | 19 ++ 4 files changed, 295 insertions(+), 210 deletions(-) create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt create mode 100644 app/src/main/java/dev/lexip/hecate/ui/setup/ShareExtensions.kt diff --git a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt index 5f16fe8..de0fce7 100644 --- a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt +++ b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt @@ -106,4 +106,32 @@ object AnalyticsLogger { } } } + + fun logSetupStarted(context: Context) { + ifAllowed { + analytics(context).logEvent("setup_started") { } + } + } + + fun logSetupStepOneCompleted(context: Context) { + ifAllowed { + analytics(context).logEvent("setup_step_completed") { + param("step_name", "enable_developer_mode") + } + } + } + + fun logSetupStepTwoCompleted(context: Context) { + ifAllowed { + analytics(context).logEvent("setup_step_completed") { + param("step_name", "connect_usb") + } + } + } + + fun logSetupFinished(context: Context) { + ifAllowed { + analytics(context).logEvent("setup_finished") { } + } + } } 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 38c7b60..dfa7970 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -66,28 +66,11 @@ import dev.lexip.hecate.ui.components.MainSwitchPreferenceCard 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.PermissionSetupWizardScreen -import dev.lexip.hecate.ui.setup.PermissionWizardStep +import dev.lexip.hecate.ui.setup.PermissionSetupHost import dev.lexip.hecate.ui.theme.hecateTopAppBarColors import java.net.URLEncoder import java.nio.charset.StandardCharsets -// Helper to share via Android Sharesheet -private fun android.content.Context.shareSetupUrl(url: String) { - if (url.isBlank()) return - - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, url) - putExtra(Intent.EXTRA_TITLE, "Setup - Adaptive Theme") - type = "text/plain" - } - - val shareIntent = Intent.createChooser(sendIntent, null) - startActivity(shareIntent) - -} - private val ScreenHorizontalMargin = 20.dp @OptIn(ExperimentalMaterial3Api::class) @@ -362,197 +345,7 @@ fun AdaptiveThemeScreen( // Show permission wizard if needed if (internalUiState.showPermissionWizard) { - var isDeveloperOptionsEnabled by remember { mutableStateOf(false) } - var isUsbDebuggingEnabled by remember { mutableStateOf(false) } - var isUsbConnected by remember { mutableStateOf(false) } - var hasPermission by remember { mutableStateOf(false) } - - // Periodically check developer settings and permission status - LaunchedEffect(Unit) { - var previousDevOptionsState = try { - Settings.Global.getInt( - context.contentResolver, - Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, - 0 - ) == 1 - } catch (_: Exception) { - false - } - - var previousUsbDebuggingState = try { - Settings.Global.getInt( - context.contentResolver, - Settings.Global.ADB_ENABLED, - 0 - ) == 1 - } catch (_: Exception) { - false - } - - - // Observe USB state via sticky broadcast and runtime receiver - val usbFilter = - android.content.IntentFilter("android.hardware.usb.action.USB_STATE") - val sticky = context.registerReceiver(null, usbFilter) - fun parseUsbIntent(intent: Intent?): Boolean { - if (intent == null) return false - val extras = intent.extras ?: return false - val connected = extras.getBoolean("connected", false) - val configured = extras.getBoolean("configured", false) - val dataConnected = extras.getBoolean("data_connected", false) - val adb = extras.getBoolean("adb", false) - val hostConnected = extras.getBoolean("host_connected", false) - return connected && (configured || dataConnected || adb || hostConnected) - } - isUsbConnected = parseUsbIntent(sticky) - var previousUsbConnected = isUsbConnected - - val runtimeReceiver = object : android.content.BroadcastReceiver() { - override fun onReceive( - ctx: android.content.Context?, - intent: Intent? - ) { - val nowConnected = parseUsbIntent(intent) - if (!previousUsbConnected && nowConnected) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - } - isUsbConnected = nowConnected - previousUsbConnected = nowConnected - } - } - context.registerReceiver(runtimeReceiver, usbFilter) - - try { - // Fallback: check attached USB devices via UsbManager - val usbManager = - context.getSystemService(android.content.Context.USB_SERVICE) as? android.hardware.usb.UsbManager - val nowConnected = (usbManager?.deviceList?.isNotEmpty() == true) - if (!previousUsbConnected && nowConnected) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - } - isUsbConnected = isUsbConnected || nowConnected - previousUsbConnected = isUsbConnected - } catch (_: Exception) { /* ignore */ - } - - try { - while (true) { - isDeveloperOptionsEnabled = try { - Settings.Global.getInt( - context.contentResolver, - Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, - 0 - ) == 1 - } catch (_: Exception) { - false - } - - isUsbDebuggingEnabled = try { - Settings.Global.getInt( - context.contentResolver, - Settings.Global.ADB_ENABLED, - 0 - ) == 1 - } catch (_: Exception) { - false - } - - hasPermission = ContextCompat.checkSelfPermission( - context, Manifest.permission.WRITE_SECURE_SETTINGS - ) == PackageManager.PERMISSION_GRANTED - - if (!previousDevOptionsState && isDeveloperOptionsEnabled) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - } - - if (!previousUsbDebuggingState && isUsbDebuggingEnabled) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - } - - previousDevOptionsState = isDeveloperOptionsEnabled - previousUsbDebuggingState = isUsbDebuggingEnabled - - // Fallback refresh: if sticky broadcast wasn’t conclusive, re-check UsbManager - if (!isUsbConnected) { - val usbManager = - context.getSystemService(android.content.Context.USB_SERVICE) as? android.hardware.usb.UsbManager - val nowConnected = usbManager?.deviceList?.isNotEmpty() == true - if (!previousUsbConnected && nowConnected) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - } - isUsbConnected = nowConnected - previousUsbConnected = nowConnected - } - - // If permission becomes granted, auto-complete wizard and enable service - if (hasPermission) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - adaptiveThemeViewModel.completePermissionWizardAndEnableService() - break - } - - // Check every second - kotlinx.coroutines.delay(1000) - } - } finally { - context.unregisterReceiver(runtimeReceiver) - } - } - - val adbCommand by adaptiveThemeViewModel.pendingAdbCommand.collectAsState() - - PermissionSetupWizardScreen( - step = internalUiState.permissionWizardStep, - adbCommand = adbCommand, - isUsbConnected = isUsbConnected, - hasWriteSecureSettings = hasPermission, - isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, - isUsbDebuggingEnabled = isUsbDebuggingEnabled, - onNext = { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - if (internalUiState.permissionWizardStep == PermissionWizardStep.GRANT_PERMISSION && hasPermission) { - adaptiveThemeViewModel.completePermissionWizardAndEnableService() - } else { - adaptiveThemeViewModel.goToNextPermissionWizardStep() - } - }, - onExit = { adaptiveThemeViewModel.dismissPermissionWizard() }, - onOpenSettings = { - val intent = Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) - try { - context.startActivity(intent) - } catch (_: Exception) { - context.startActivity(Intent(Settings.ACTION_SETTINGS)) - } - }, - onOpenDeveloperSettings = { - val intent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS) - try { - context.startActivity(intent) - } catch (_: Exception) { - context.startActivity(Intent(Settings.ACTION_SETTINGS)) - } - }, - onShareSetupUrl = { - AnalyticsLogger.logShareLinkClicked(context, "permission_wizard") - context.shareSetupUrl("https://lexip.dev/setup") - }, - onCopyAdbCommand = { adaptiveThemeViewModel.requestCopyAdbCommand() }, - onShareExpertCommand = { - context.shareSetupUrl(adbCommand) - }, - onCheckPermission = { - val nowGranted = - ContextCompat.checkSelfPermission( - context, Manifest.permission.WRITE_SECURE_SETTINGS - ) == PackageManager.PERMISSION_GRANTED - adaptiveThemeViewModel.recheckWriteSecureSettingsPermission(nowGranted) - if (nowGranted) { - haptic.performHapticFeedback(HapticFeedbackType.ContextClick) - adaptiveThemeViewModel.completePermissionWizardAndEnableService() - } - } - ) + PermissionSetupHost(viewModel = adaptiveThemeViewModel) return } @@ -565,4 +358,5 @@ fun AdaptiveThemeScreen( }, onDismiss = { showCustomDialog.value = false } ) -} \ No newline at end of file +} + 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 new file mode 100644 index 0000000..b3568e5 --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupHost.kt @@ -0,0 +1,244 @@ +package dev.lexip.hecate.ui.setup + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.analytics.AnalyticsLogger +import dev.lexip.hecate.ui.AdaptiveThemeViewModel + +@Composable +fun PermissionSetupHost( + viewModel: AdaptiveThemeViewModel, +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val internalUiState by viewModel.uiState.collectAsState() + + // Track setup-related environment state locally in this host + var isDeveloperOptionsEnabled by remember { mutableStateOf(false) } + var isUsbDebuggingEnabled by remember { mutableStateOf(false) } + var isUsbConnected by remember { mutableStateOf(false) } + var hasPermission by remember { mutableStateOf(false) } + + SideEffect { + AnalyticsLogger.logSetupStarted(context) + } + + // Periodically check developer settings, USB and permission status + LaunchedEffect(Unit) { + var previousDevOptionsState = try { + Settings.Global.getInt( + context.contentResolver, + Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, + 0 + ) == 1 + } catch (_: Exception) { + false + } + + var previousUsbDebuggingState = try { + Settings.Global.getInt( + context.contentResolver, + Settings.Global.ADB_ENABLED, + 0 + ) == 1 + } catch (_: Exception) { + false + } + + // Observe USB state via sticky broadcast and runtime receiver + val usbFilter = + android.content.IntentFilter("android.hardware.usb.action.USB_STATE") + val sticky = context.registerReceiver(null, usbFilter) + fun parseUsbIntent(intent: Intent?): Boolean { + if (intent == null) return false + val extras = intent.extras ?: return false + val connected = extras.getBoolean("connected", false) + val configured = extras.getBoolean("configured", false) + val dataConnected = extras.getBoolean("data_connected", false) + val adb = extras.getBoolean("adb", false) + val hostConnected = extras.getBoolean("host_connected", false) + return connected && (configured || dataConnected || adb || hostConnected) + } + isUsbConnected = parseUsbIntent(sticky) + var previousUsbConnected = isUsbConnected + + val runtimeReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive( + ctx: android.content.Context?, + intent: Intent? + ) { + val nowConnected = parseUsbIntent(intent) + if (!previousUsbConnected && nowConnected) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + isUsbConnected = nowConnected + previousUsbConnected = nowConnected + } + } + context.registerReceiver(runtimeReceiver, usbFilter) + + try { + // Fallback: check attached USB devices via UsbManager + val usbManager = + context.getSystemService(android.content.Context.USB_SERVICE) as? android.hardware.usb.UsbManager + val nowConnected = (usbManager?.deviceList?.isNotEmpty() == true) + if (!previousUsbConnected && nowConnected) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + isUsbConnected = isUsbConnected || nowConnected + previousUsbConnected = isUsbConnected + } catch (_: Exception) { + // ignore + } + + try { + while (true) { + isDeveloperOptionsEnabled = try { + Settings.Global.getInt( + context.contentResolver, + Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, + 0 + ) == 1 + } catch (_: Exception) { + false + } + + isUsbDebuggingEnabled = try { + Settings.Global.getInt( + context.contentResolver, + Settings.Global.ADB_ENABLED, + 0 + ) == 1 + } catch (_: Exception) { + false + } + + hasPermission = ContextCompat.checkSelfPermission( + context, Manifest.permission.WRITE_SECURE_SETTINGS + ) == PackageManager.PERMISSION_GRANTED + + if (!previousDevOptionsState && isDeveloperOptionsEnabled) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + + if (!previousUsbDebuggingState && isUsbDebuggingEnabled) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + + previousDevOptionsState = isDeveloperOptionsEnabled + previousUsbDebuggingState = isUsbDebuggingEnabled + + // Fallback refresh: if sticky broadcast wasn’t conclusive, re-check UsbManager + if (!isUsbConnected) { + val usbManager = + context.getSystemService(android.content.Context.USB_SERVICE) as? android.hardware.usb.UsbManager + val nowConnected = usbManager?.deviceList?.isNotEmpty() == true + if (!previousUsbConnected && nowConnected) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + } + isUsbConnected = nowConnected + previousUsbConnected = nowConnected + } + + // If permission becomes granted, auto-complete wizard and enable service + if (hasPermission) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + viewModel.completePermissionWizardAndEnableService() + break + } + + // Check every second + kotlinx.coroutines.delay(1000) + } + } finally { + context.unregisterReceiver(runtimeReceiver) + } + } + + val adbCommand by viewModel.pendingAdbCommand.collectAsState() + + PermissionSetupWizardScreen( + step = internalUiState.permissionWizardStep, + adbCommand = adbCommand, + isUsbConnected = isUsbConnected, + hasWriteSecureSettings = hasPermission, + isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, + isUsbDebuggingEnabled = isUsbDebuggingEnabled, + onNext = { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + when (internalUiState.permissionWizardStep) { + PermissionWizardStep.ENABLE_DEVELOPER_MODE -> { + AnalyticsLogger.logSetupStepOneCompleted(context) + viewModel.goToNextPermissionWizardStep() + } + + PermissionWizardStep.CONNECT_USB -> { + AnalyticsLogger.logSetupStepTwoCompleted(context) + viewModel.goToNextPermissionWizardStep() + } + + PermissionWizardStep.GRANT_PERMISSION -> { + if (hasPermission) { + AnalyticsLogger.logSetupFinished(context) + viewModel.completePermissionWizardAndEnableService() + } else { + viewModel.goToNextPermissionWizardStep() + } + } + } + }, + onExit = { + viewModel.dismissPermissionWizard() + }, + onOpenSettings = { + val intent = Intent(Settings.ACTION_DEVICE_INFO_SETTINGS) + try { + context.startActivity(intent) + } catch (_: Exception) { + context.startActivity(Intent(Settings.ACTION_SETTINGS)) + } + }, + onOpenDeveloperSettings = { + val intent = Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS) + try { + context.startActivity(intent) + } catch (_: Exception) { + context.startActivity(Intent(Settings.ACTION_SETTINGS)) + } + }, + onShareSetupUrl = { + AnalyticsLogger.logShareLinkClicked(context, "permission_wizard") + context.shareSetupUrl("https://lexip.dev/setup") + }, + onCopyAdbCommand = { viewModel.requestCopyAdbCommand() }, + onShareExpertCommand = { + context.shareSetupUrl(adbCommand) + }, + onCheckPermission = { + val nowGranted = + ContextCompat.checkSelfPermission( + context, Manifest.permission.WRITE_SECURE_SETTINGS + ) == PackageManager.PERMISSION_GRANTED + viewModel.recheckWriteSecureSettingsPermission(nowGranted) + if (nowGranted) { + haptic.performHapticFeedback(HapticFeedbackType.ContextClick) + AnalyticsLogger.logSetupFinished(context) + viewModel.completePermissionWizardAndEnableService() + } + } + ) +} 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 new file mode 100644 index 0000000..3e5b8e0 --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/ShareExtensions.kt @@ -0,0 +1,19 @@ +package dev.lexip.hecate.ui.setup + +import android.content.Intent + +// Helper to share a URL via Android Sharesheet, reused by setup components. +internal fun android.content.Context.shareSetupUrl(url: String) { + if (url.isBlank()) return + + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, url) + putExtra(Intent.EXTRA_TITLE, "Setup - Adaptive Theme") + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null) + startActivity(shareIntent) +} + From 6bc457262f7cdad2d4da61f9ffbb20091d1cc42c Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 09:30:59 +0100 Subject: [PATCH 03/23] fix(broadcasts): Listen for MY_PACKAGE_REPLACED to restart service after update * Fixes #58 --- app/src/main/AndroidManifest.xml | 5 ++++- ...tedReceiver.kt => AppLifecycleReceiver.kt} | 22 ++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) rename app/src/main/java/dev/lexip/hecate/broadcasts/{BootCompletedReceiver.kt => AppLifecycleReceiver.kt} (52%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 489edef..627ea3c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -66,11 +66,14 @@ + + + { + Log.i(TAG, "Boot completed, starting broadcast receiver service...") + val serviceIntent = Intent(context, BroadcastReceiverService::class.java) + ContextCompat.startForegroundService(context, serviceIntent) + } + + Intent.ACTION_MY_PACKAGE_REPLACED -> { + Log.i(TAG, "App package replaced, starting broadcast receiver service if needed...") + val serviceIntent = Intent(context, BroadcastReceiverService::class.java) + ContextCompat.startForegroundService(context, serviceIntent) + } + + else -> { + Log.d(TAG, "Received unrelated intent action: ${intent.action}") + } } } From d2296abe97222cffc5579908fac1e9b1569693a1 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 10:03:08 +0100 Subject: [PATCH 04/23] feat: Add a "Star on GitHub" action * Added to the three-dot menu --- .../lexip/hecate/ui/AdaptiveThemeScreen.kt | 23 +++++++++++++++++-- app/src/main/res/values/strings.xml | 1 + 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt index dfa7970..ac709ce 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -219,7 +219,7 @@ fun AdaptiveThemeScreen( } ) - // 3) Beta Feedback (only on beta builds) + // 4) Beta Feedback (only on beta builds) if (BuildConfig.VERSION_NAME.contains("-beta")) { DropdownMenuItem( text = { Text(text = "Beta Feedback") }, @@ -237,7 +237,26 @@ fun AdaptiveThemeScreen( ) } - // 4) About + // 5) Star on GitHub + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_star_on_github)) }, + onClick = { + menuExpanded = false + AnalyticsLogger.logOverflowMenuItemClicked( + context, + "star_github" + ) + val starUri = "https://lexip.dev/hecate/source".toUri() + val starIntent = Intent(Intent.ACTION_VIEW, starUri) + try { + context.startActivity(starIntent) + } catch (_: ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, starUri)) + } + } + ) + + // 6) About DropdownMenuItem( text = { Text(stringResource(R.string.title_about)) }, onClick = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66d7063..1e78fe3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ Adaptive Theme lexip.dev/setup + Star on GitHub Cancel From 1156bc41d766c20ac2229ad9afda3915bd4496b6 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 11:51:38 +0100 Subject: [PATCH 05/23] chore: Require telephony to support foldables but not tablets * Related to #60 * Just one of multiple safeguards. * Adaptive Theme is unavailable on Tablets for technical reasons. --- app/src/main/AndroidManifest.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 627ea3c..181296d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,10 +23,16 @@ + + + From 428ca415f88e541ac8f3380573a6348eaef52791 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 12:10:17 +0100 Subject: [PATCH 06/23] refactor: Use the new InstallSourceChecker util where applicable --- .../lexip/hecate/analytics/AnalyticsGate.kt | 17 ++++---- .../lexip/hecate/ui/AdaptiveThemeScreen.kt | 3 +- .../dev/lexip/hecate/ui/InAppUpdateManager.kt | 42 ++++++++++--------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsGate.kt b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsGate.kt index f79b7d4..67143db 100644 --- a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsGate.kt +++ b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsGate.kt @@ -14,6 +14,7 @@ package dev.lexip.hecate.analytics import android.content.Context import com.google.firebase.analytics.FirebaseAnalytics +import dev.lexip.hecate.util.InstallSourceChecker /** * Controls whether analytics collection is enabled. @@ -24,18 +25,16 @@ object AnalyticsGate { @Volatile private var enabled = false + @Volatile + private var playStoreInstall = false + fun init(context: Context) { - val pm = context.packageManager - val installer = try { - pm.getInstallSourceInfo(context.packageName).installingPackageName - ?: pm.getInstallSourceInfo(context.packageName).initiatingPackageName - } catch (_: Exception) { - null - } - val isGooglePlayInstall = installer == "com.android.vending" - enabled = isGooglePlayInstall + playStoreInstall = InstallSourceChecker.isInstalledFromPlayStore(context) + enabled = playStoreInstall FirebaseAnalytics.getInstance(context).setAnalyticsCollectionEnabled(enabled) } fun allowed(): Boolean = enabled + + fun isPlayStoreInstall(): Boolean = playStoreInstall } 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 ac709ce..7c983a2 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -60,6 +60,7 @@ import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import dev.lexip.hecate.BuildConfig import dev.lexip.hecate.R +import dev.lexip.hecate.analytics.AnalyticsGate import dev.lexip.hecate.analytics.AnalyticsLogger import dev.lexip.hecate.data.AdaptiveThreshold import dev.lexip.hecate.ui.components.MainSwitchPreferenceCard @@ -220,7 +221,7 @@ fun AdaptiveThemeScreen( ) // 4) Beta Feedback (only on beta builds) - if (BuildConfig.VERSION_NAME.contains("-beta")) { + if (BuildConfig.VERSION_NAME.contains("-beta") && AnalyticsGate.isPlayStoreInstall()) { DropdownMenuItem( text = { Text(text = "Beta Feedback") }, onClick = { 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 148c8a8..3c2d75b 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt @@ -24,6 +24,8 @@ import com.google.android.play.core.appupdate.AppUpdateManagerFactory import com.google.android.play.core.appupdate.AppUpdateOptions import com.google.android.play.core.install.model.AppUpdateType import com.google.android.play.core.install.model.UpdateAvailability +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 @@ -31,18 +33,24 @@ private const val MIN_PRIORITY_FOR_IMMEDIATE = 0 class InAppUpdateManager(activity: ComponentActivity) { - private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity) + private val appUpdateManager: AppUpdateManager? = if (AnalyticsGate.isPlayStoreInstall()) { + AppUpdateManagerFactory.create(activity) + } else null private var updateLauncher: ActivityResultLauncher? = null fun registerUpdateLauncher(activity: ComponentActivity) { if (updateLauncher != null) return + if (!AnalyticsGate.isPlayStoreInstall()) { + return + } + appUpdateManager ?: return updateLauncher = activity.registerForActivityResult(StartIntentSenderForResult()) { result -> when (result.resultCode) { Activity.RESULT_OK -> { - Log.d(TAG, "In-app update completed successfully") + Log.i(TAG, "In-app update completed successfully") } Activity.RESULT_CANCELED -> { @@ -70,29 +78,28 @@ class InAppUpdateManager(activity: ComponentActivity) { onNoUpdate: () -> Unit = {}, onError: (Throwable) -> Unit = {} ) { + if (!AnalyticsGate.isPlayStoreInstall()) { + return + } val launcher = updateLauncher if (launcher == null) { Log.w(TAG, "checkForImmediateUpdate called before launcher was registered") return } + val manager = appUpdateManager ?: return - appUpdateManager.appUpdateInfo + manager.appUpdateInfo .addOnSuccessListener { appUpdateInfo -> val availability = appUpdateInfo.updateAvailability() val isImmediateAllowed = appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) val staleness = appUpdateInfo.clientVersionStalenessDays() ?: -1 val priority = appUpdateInfo.updatePriority() - Log.d( - TAG, - "Update info: availability=$availability, immediateAllowed=$isImmediateAllowed, stalenessDays=$staleness, priority=$priority" - ) - val meetsStaleness = staleness == -1 || staleness >= DAYS_FOR_IMMEDIATE_UPDATE val meetsPriority = priority >= MIN_PRIORITY_FOR_IMMEDIATE if (availability == UpdateAvailability.UPDATE_AVAILABLE && isImmediateAllowed && meetsStaleness && meetsPriority) { - Log.d(TAG, "Immediate update available, starting update flow") + Log.i(TAG, "Immediate in-app update: starting update flow") try { appUpdateManager.startUpdateFlowForResult( appUpdateInfo, @@ -104,10 +111,6 @@ class InAppUpdateManager(activity: ComponentActivity) { onError(t) } } else { - Log.d( - TAG, - "No eligible immediate update. availability=$availability, immediateAllowed=$isImmediateAllowed" - ) onNoUpdate() } } @@ -118,18 +121,22 @@ class InAppUpdateManager(activity: ComponentActivity) { } fun resumeImmediateUpdateIfNeeded() { + if (!AnalyticsGate.isPlayStoreInstall()) { + return + } val launcher = updateLauncher if (launcher == null) { Log.w(TAG, "resumeImmediateUpdateIfNeeded called before launcher was registered") return } + val manager = appUpdateManager ?: return - appUpdateManager.appUpdateInfo + manager.appUpdateInfo .addOnSuccessListener { appUpdateInfo -> if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) ) { - Log.d(TAG, "Resuming in-progress immediate in-app update") + Log.i(TAG, "Resuming in-progress immediate in-app update") try { appUpdateManager.startUpdateFlowForResult( appUpdateInfo, @@ -139,11 +146,6 @@ class InAppUpdateManager(activity: ComponentActivity) { } catch (t: Throwable) { Log.e(TAG, "Failed to resume immediate in-app update", t) } - } else { - Log.d( - TAG, - "No in-progress immediate update to resume. availability=${appUpdateInfo.updateAvailability()}" - ) } } .addOnFailureListener { throwable -> From 3b193f6220292e259064345fcab7dc7307c0e8c3 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 12:10:37 +0100 Subject: [PATCH 07/23] chore(analytics): Log successful in-app updates --- .../main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt | 6 ++++++ app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt | 1 + 2 files changed, 7 insertions(+) diff --git a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt index de0fce7..a5f2dad 100644 --- a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt +++ b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt @@ -134,4 +134,10 @@ object AnalyticsLogger { analytics(context).logEvent("setup_finished") { } } } + + fun logInAppUpdateInstalled(context: Context) { + ifAllowed { + analytics(context).logEvent("in_app_update_installed") { } + } + } } 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 3c2d75b..0afed3e 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt @@ -51,6 +51,7 @@ class InAppUpdateManager(activity: ComponentActivity) { when (result.resultCode) { Activity.RESULT_OK -> { Log.i(TAG, "In-app update completed successfully") + AnalyticsLogger.logInAppUpdateInstalled(activity) } Activity.RESULT_CANCELED -> { From fecf527c10112a1b1b5f03533941cecb35e5827a Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 11:59:57 +0100 Subject: [PATCH 08/23] fix: Enable in-app updates only when the installer is Google Play * Fixes #59 * New InstallSourceChecker util --- .../java/dev/lexip/hecate/ui/MainActivity.kt | 21 ++++---- .../lexip/hecate/util/InstallSourceChecker.kt | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt 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 bba79de..461e890 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt @@ -27,13 +27,16 @@ import dev.lexip.hecate.HecateApplication import dev.lexip.hecate.data.UserPreferencesRepository import dev.lexip.hecate.ui.theme.HecateTheme import dev.lexip.hecate.util.DarkThemeHandler +import dev.lexip.hecate.util.InstallSourceChecker class MainActivity : ComponentActivity() { - private lateinit var inAppUpdateManager: InAppUpdateManager + private var inAppUpdateManager: InAppUpdateManager? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + installSplashScreen() + enableEdgeToEdge() // Catch mysterious unsupported SDK versions despite minSDK 31 @SuppressLint("ObsoleteSdkInt") @@ -47,11 +50,13 @@ class MainActivity : ComponentActivity() { return } - installSplashScreen() - enableEdgeToEdge() - inAppUpdateManager = InAppUpdateManager(this) - inAppUpdateManager.registerUpdateLauncher(this) + val isPlayInstall = InstallSourceChecker.isInstalledFromPlayStore(this) + if (isPlayInstall) { + inAppUpdateManager = InAppUpdateManager(this).also { manager -> + manager.registerUpdateLauncher(this) + } + } setContent { val dataStore = (this.applicationContext as HecateApplication).userPreferencesDataStore @@ -71,13 +76,11 @@ class MainActivity : ComponentActivity() { } } - inAppUpdateManager.checkForImmediateUpdate() + inAppUpdateManager?.checkForImmediateUpdate() } override fun onResume() { super.onResume() - if (::inAppUpdateManager.isInitialized) { - inAppUpdateManager.resumeImmediateUpdateIfNeeded() - } + inAppUpdateManager?.resumeImmediateUpdateIfNeeded() } } diff --git a/app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt b/app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt new file mode 100644 index 0000000..889e93f --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/util/InstallSourceChecker.kt @@ -0,0 +1,51 @@ +/* + * 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.util + +import android.content.Context +import android.os.Build +import android.util.Log + +object InstallSourceChecker { + + private const val TAG = "InstallSourceChecker" + private const val PLAY_STORE_PACKAGE = "com.android.vending" + + /** + * Returns true only if the app was installed from the Google Play Store. + * On any error or unknown installer, this returns false to safely disable in-app updates. + */ + fun isInstalledFromPlayStore(context: Context): Boolean { + val installer = getInstallerPackageName(context) + val fromPlay = installer == PLAY_STORE_PACKAGE + Log.d(TAG, "Installer package: $installer, isPlayStore=$fromPlay") + return fromPlay + } + + private fun getInstallerPackageName(context: Context): String? { + 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) + } + } catch (t: Throwable) { + Log.w(TAG, "Failed to resolve installer package", t) + null + } + } +} + From 0893e7a3a9f7e06eeaef84cc7ba556555f95eaeb Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 14:28:25 +0100 Subject: [PATCH 09/23] feat(ui): Show a warning when the device is covered * Explain that the service won't switch the theme when the device is covered. --- .../lexip/hecate/ui/AdaptiveThemeScreen.kt | 35 +++++++++++++++- .../lexip/hecate/ui/AdaptiveThemeViewModel.kt | 41 +++++++++++++++++-- app/src/main/res/values-de/strings.xml | 5 ++- app/src/main/res/values/strings.xml | 5 ++- 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt index 7c983a2..941915a 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -22,12 +22,17 @@ import android.provider.Settings import android.widget.Toast 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.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -135,7 +140,7 @@ fun AdaptiveThemeScreen( LargeTopAppBar( modifier = Modifier .padding(start = ScreenHorizontalMargin - 8.dp) - .padding(top = 22.dp, bottom = 12.dp), + .padding(top = 12.dp, bottom = 12.dp), colors = hecateTopAppBarColors(), title = { Text( @@ -291,7 +296,7 @@ fun AdaptiveThemeScreen( .padding(innerPadding) .padding(horizontal = ScreenHorizontalMargin) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(32.dp) + verticalArrangement = Arrangement.spacedBy(24.dp) ) { Text( @@ -299,6 +304,7 @@ fun AdaptiveThemeScreen( text = stringResource(id = R.string.description_adaptive_theme), style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 21.sp) ) + MainSwitchPreferenceCard( text = stringResource(id = R.string.action_use_adaptive_theme), isChecked = uiState.adaptiveThemeEnabled, @@ -360,6 +366,31 @@ fun AdaptiveThemeScreen( ) } + + // Device-covered warning when the proximity sensor reports covered + if (internalUiState.isDeviceCovered && uiState.adaptiveThemeEnabled) { + Card( + modifier = Modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + shape = RoundedCornerShape(20.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(id = R.string.device_covered_title), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.padding(top = 4.dp)) + Text( + text = stringResource(id = R.string.device_covered_message), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } } } 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 67559d2..cc6356d 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -25,6 +25,7 @@ import dev.lexip.hecate.services.BroadcastReceiverService 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 kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -45,7 +46,8 @@ data class AdaptiveThemeUiState( val permissionWizardStep: PermissionWizardStep = PermissionWizardStep.ENABLE_DEVELOPER_MODE, val permissionWizardCompleted: Boolean = false, val hasAutoAdvancedFromDeveloperMode: Boolean = false, - val hasAutoAdvancedFromConnectUsb: Boolean = false + val hasAutoAdvancedFromConnectUsb: Boolean = false, + val isDeviceCovered: Boolean = false ) class AdaptiveThemeViewModel( @@ -82,21 +84,51 @@ class AdaptiveThemeViewModel( _currentSensorLux.value = lux } + // Proximity Sensor + private val proximitySensorManager = ProximitySensorManager(application.applicationContext) + private var isListeningToProximity = false + + private fun startProximityListening() { + if (isListeningToProximity) return + isListeningToProximity = true + proximitySensorManager.startListening { distance: Float -> + val covered = distance < 5f + if (covered != _uiState.value.isDeviceCovered) { + if (!covered) Thread.sleep(300) // Prevents UI flickering + _uiState.value = _uiState.value.copy(isDeviceCovered = covered) + } + } + } + + private fun stopProximityListening() { + if (!isListeningToProximity) return + isListeningToProximity = false + proximitySensorManager.stopListening() + if (_uiState.value.isDeviceCovered) { + _uiState.value = _uiState.value.copy(isDeviceCovered = false) + } + } + // Temporary variable for custom threshold private var customThresholdTemp: Float? = null init { viewModelScope.launch { userPreferencesRepository.userPreferencesFlow.collect { userPreferences -> - _uiState.value = AdaptiveThemeUiState( + _uiState.value = _uiState.value.copy( adaptiveThemeEnabled = userPreferences.adaptiveThemeEnabled, adaptiveThemeThresholdLux = userPreferences.adaptiveThemeThresholdLux, customAdaptiveThemeThresholdLux = userPreferences.customAdaptiveThemeThresholdLux, permissionWizardCompleted = userPreferences.permissionWizardCompleted ) - if (userPreferences.adaptiveThemeEnabled) startLightSensorListening() - else stopLightSensorListening() + if (userPreferences.adaptiveThemeEnabled) { + startLightSensorListening() + startProximityListening() + } else { + stopLightSensorListening() + stopProximityListening() + } } } } @@ -119,6 +151,7 @@ class AdaptiveThemeViewModel( override fun onCleared() { stopLightSensorListening() + stopProximityListening() super.onCleared() } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 91a8285..3ab46a2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -14,13 +14,16 @@ Adaptive Theme verwenden - Wechselt automatisch zwischen hellem und dunklem Modus basierend auf dem Umgebungslicht – für bessere Sichtbarkeit und Akkulaufzeit. Der Wechsel erfolgt nur beim Einschalten des Bildschirms und nur, wenn das Gerät nicht abgedeckt ist. + Wechselt automatisch zwischen hellem und dunklem Modus basierend auf dem Umgebungslicht – für bessere Sichtbarkeit und Akkulaufzeit. Änderungen werden nur direkt nach Einschalten des Bildschirms vorgenommen. Systemtheme wird anhand der Umgebungshelligkeit angepasst. + Gerät bedeckt + Das Theme wird nur geändert, wenn das Gerät nicht bedeckt ist. + Ungültiger Lux-Wert. Der Lux-Wert kann maximal 100.000 sein. Der Lux-Wert darf nicht negativ sein. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e78fe3..8d47e13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,12 +17,15 @@ Use Adaptive Theme - Automatically switches between Light and Dark mode based on ambient light for better visibility and battery life. Theme changes occur only when the screen turns on and the device is uncovered. + Automatically switches between Light and Dark mode based on ambient light for better visibility and battery life. Changes are only made right after the screen turns on. Adjusting the system theme based on ambient brightness. + Device covered + System theme changes are only made when the device is not covered. + No email app found. Invalid lux value. Lux value cannot be negative. From 74971a6c0ecb58482cff2353c93cf598229a658e Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 17:23:29 +0100 Subject: [PATCH 10/23] feat: Use SENSOR_DELAY_UI in UI use cases --- .../java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt | 9 ++++----- .../java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt | 9 +++++---- .../java/dev/lexip/hecate/util/LightSensorManager.kt | 7 +++++-- .../java/dev/lexip/hecate/util/ProximitySensorManager.kt | 7 +++++-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt b/app/src/main/java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt index 47583ff..c2f575d 100644 --- a/app/src/main/java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt +++ b/app/src/main/java/dev/lexip/hecate/broadcasts/ScreenOnReceiver.kt @@ -38,18 +38,17 @@ class ScreenOnReceiver( Log.d(TAG, "Screen turned on, checking adaptive theme conditions...") // Check if the device is covered using the proximity sensor - proximitySensorManager.startListening { distance -> + proximitySensorManager.startListening({ distance: Float -> proximitySensorManager.stopListening() // If the device is not covered, change the device theme based on the environment if (distance >= 5f) { - lightSensorManager.startListening { lightValue -> + lightSensorManager.startListening({ lightValue: Float -> lightSensorManager.stopListening() darkThemeHandler.setDarkTheme(lightValue < adaptiveThemeThresholdLux) - } + }) } else Log.d(TAG, "Device is covered, skipping adaptive theme checks.") - - } + }) } } } \ No newline at end of file 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 cc6356d..8fee39e 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -13,6 +13,7 @@ package dev.lexip.hecate.ui import android.content.Intent +import android.hardware.SensorManager import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -91,13 +92,13 @@ class AdaptiveThemeViewModel( private fun startProximityListening() { if (isListeningToProximity) return isListeningToProximity = true - proximitySensorManager.startListening { distance: Float -> + proximitySensorManager.startListening({ distance: Float -> val covered = distance < 5f if (covered != _uiState.value.isDeviceCovered) { if (!covered) Thread.sleep(300) // Prevents UI flickering _uiState.value = _uiState.value.copy(isDeviceCovered = covered) } - } + }, sensorDelay = SensorManager.SENSOR_DELAY_UI) } private fun stopProximityListening() { @@ -136,11 +137,11 @@ class AdaptiveThemeViewModel( private fun startLightSensorListening() { if (isListeningToSensor) return isListeningToSensor = true - lightSensorManager.startListening { lux -> + lightSensorManager.startListening({ lux: Float -> viewModelScope.launch { updateCurrentSensorLux(lux) } - } + }, sensorDelay = SensorManager.SENSOR_DELAY_UI) } private fun stopLightSensorListening() { diff --git a/app/src/main/java/dev/lexip/hecate/util/LightSensorManager.kt b/app/src/main/java/dev/lexip/hecate/util/LightSensorManager.kt index 7b05dfa..2471250 100644 --- a/app/src/main/java/dev/lexip/hecate/util/LightSensorManager.kt +++ b/app/src/main/java/dev/lexip/hecate/util/LightSensorManager.kt @@ -28,10 +28,13 @@ class LightSensorManager(private val context: Context) : SensorEventListener { private val lightSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT) private lateinit var callback: (Float) -> Unit - fun startListening(callback: (Float) -> Unit) { + fun startListening( + callback: (Float) -> Unit, + sensorDelay: Int = SensorManager.SENSOR_DELAY_FASTEST + ) { this.callback = callback lightSensor?.let { - sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_FASTEST) + sensorManager.registerListener(this, it, sensorDelay) } } diff --git a/app/src/main/java/dev/lexip/hecate/util/ProximitySensorManager.kt b/app/src/main/java/dev/lexip/hecate/util/ProximitySensorManager.kt index 68cf2d7..daf3b21 100644 --- a/app/src/main/java/dev/lexip/hecate/util/ProximitySensorManager.kt +++ b/app/src/main/java/dev/lexip/hecate/util/ProximitySensorManager.kt @@ -28,10 +28,13 @@ class ProximitySensorManager(private val context: Context) : SensorEventListener private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY) private lateinit var callback: (Float) -> Unit - fun startListening(callback: (Float) -> Unit) { + fun startListening( + callback: (Float) -> Unit, + sensorDelay: Int = SensorManager.SENSOR_DELAY_FASTEST + ) { this.callback = callback proximitySensor?.let { - sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_FASTEST) + sensorManager.registerListener(this, it, sensorDelay) } } From f3023a2f3f315dcf12ed4191b1f6ff89f7edc719 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 16:54:39 +0100 Subject: [PATCH 11/23] refactor(analytics): Update setup step log events --- .../java/dev/lexip/hecate/analytics/AnalyticsLogger.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt index a5f2dad..6ab113e 100644 --- a/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt +++ b/app/src/main/java/dev/lexip/hecate/analytics/AnalyticsLogger.kt @@ -115,17 +115,13 @@ object AnalyticsLogger { fun logSetupStepOneCompleted(context: Context) { ifAllowed { - analytics(context).logEvent("setup_step_completed") { - param("step_name", "enable_developer_mode") - } + analytics(context).logEvent("setup_step_one_completed") { } } } fun logSetupStepTwoCompleted(context: Context) { ifAllowed { - analytics(context).logEvent("setup_step_completed") { - param("step_name", "connect_usb") - } + analytics(context).logEvent("setup_step_two_completed") { } } } From 36887a30bc3658c81a37e7fd9d42c028551f7b6b Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 18:02:35 +0100 Subject: [PATCH 12/23] refactor: Outsource three-dot menu code --- .../lexip/hecate/ui/AdaptiveThemeScreen.kt | 162 ++--------------- .../hecate/ui/components/ThreeDotMenu.kt | 170 ++++++++++++++++++ 2 files changed, 180 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt index 941915a..c5405d4 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -13,13 +13,9 @@ package dev.lexip.hecate.ui import android.Manifest -import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager -import android.content.Intent import android.content.pm.PackageManager -import android.provider.Settings -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -29,15 +25,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -50,34 +40,30 @@ import androidx.compose.runtime.collectAsState 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel -import dev.lexip.hecate.BuildConfig import dev.lexip.hecate.R -import dev.lexip.hecate.analytics.AnalyticsGate -import dev.lexip.hecate.analytics.AnalyticsLogger import dev.lexip.hecate.data.AdaptiveThreshold import dev.lexip.hecate.ui.components.MainSwitchPreferenceCard +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 java.net.URLEncoder -import java.nio.charset.StandardCharsets private val ScreenHorizontalMargin = 20.dp +private val horizontalOffsetPadding = 8.dp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,15 +73,13 @@ fun AdaptiveThemeScreen( ) { // Enable top-app-bar collapsing on small devices val windowInfo = LocalWindowInfo.current - val density = androidx.compose.ui.platform.LocalDensity.current + val density = LocalDensity.current val screenHeightDp = with(density) { windowInfo.containerSize.height.toDp().value } val enableCollapsing = screenHeightDp < 700f val scrollBehavior = if (enableCollapsing) { TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) } else null - val horizontalOffsetPadding = 8.dp - val context = LocalContext.current val haptic = LocalHapticFeedback.current val packageName = context.packageName @@ -153,138 +137,12 @@ fun AdaptiveThemeScreen( ) }, actions = { - stringResource(id = R.string.error_no_email_client) - var menuExpanded by remember { mutableStateOf(false) } - androidx.compose.foundation.layout.Box { - IconButton(onClick = { menuExpanded = true }) { - Icon( - imageVector = Icons.Filled.MoreVert, - contentDescription = stringResource(id = R.string.title_more) - ) - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false } - ) { - val feedbackSubject = - "Adaptive Theme Feedback (v${BuildConfig.VERSION_NAME})" - - // 1) Custom Threshold - DropdownMenuItem( - text = { Text(text = stringResource(id = R.string.title_custom_threshold)) }, - enabled = uiState.adaptiveThemeEnabled, - onClick = { - menuExpanded = false - AnalyticsLogger.logOverflowMenuItemClicked( - context, - "custom_threshold" - ) - if (uiState.adaptiveThemeEnabled) { - showCustomDialog.value = true - } - } - ) - - // 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) - } - ) - } - - // 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( - feedbackSubject, - StandardCharsets.UTF_8.toString() - ) - 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()) { - 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) - } - ) - } - - // 5) Star on GitHub - DropdownMenuItem( - text = { Text(text = stringResource(id = R.string.action_star_on_github)) }, - onClick = { - menuExpanded = false - AnalyticsLogger.logOverflowMenuItemClicked( - context, - "star_github" - ) - val starUri = "https://lexip.dev/hecate/source".toUri() - val starIntent = Intent(Intent.ACTION_VIEW, starUri) - try { - context.startActivity(starIntent) - } catch (_: ActivityNotFoundException) { - context.startActivity(Intent(Intent.ACTION_VIEW, starUri)) - } - } - ) - - // 6) About - DropdownMenuItem( - text = { Text(stringResource(R.string.title_about)) }, - onClick = { - menuExpanded = false - AnalyticsLogger.logOverflowMenuItemClicked(context, "about") - val aboutUri = "https://lexip.dev/hecate/about".toUri() - val aboutIntent = Intent(Intent.ACTION_VIEW, aboutUri) - Toast.makeText( - context, - "v${BuildConfig.VERSION_NAME}", - Toast.LENGTH_SHORT - ).show() - try { - context.startActivity(aboutIntent) - } catch (_: ActivityNotFoundException) { - context.startActivity(Intent(Intent.ACTION_VIEW, aboutUri)) - } - onAboutClick() - } - ) - } - } + ThreeDotMenu( + isAdaptiveThemeEnabled = uiState.adaptiveThemeEnabled, + packageName = packageName, + onShowCustomThresholdDialog = { showCustomDialog.value = true }, + onAboutClick = onAboutClick + ) }, scrollBehavior = scrollBehavior ) 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 new file mode 100644 index 0000000..9125bac --- /dev/null +++ b/app/src/main/java/dev/lexip/hecate/ui/components/ThreeDotMenu.kt @@ -0,0 +1,170 @@ +package dev.lexip.hecate.ui.components + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.provider.Settings +import android.widget.Toast +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.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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import dev.lexip.hecate.BuildConfig +import dev.lexip.hecate.R +import dev.lexip.hecate.analytics.AnalyticsGate +import dev.lexip.hecate.analytics.AnalyticsLogger +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + + +const val FEEDBACK_SUBJECT = "Adaptive Theme Feedback (v${BuildConfig.VERSION_NAME})" + +@Composable +fun ThreeDotMenu( + isAdaptiveThemeEnabled: Boolean, + packageName: String, + onShowCustomThresholdDialog: () -> Unit, + onAboutClick: () -> Unit = {} +) { + val context = LocalContext.current + var menuExpanded by remember { mutableStateOf(false) } + + androidx.compose.foundation.layout.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() + } + } + ) + + // 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) + } + ) + } + + // 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() + ) + 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()) { + 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) + } + ) + } + + // 5) Star on GitHub + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.action_star_on_github)) }, + onClick = { + menuExpanded = false + AnalyticsLogger.logOverflowMenuItemClicked( + context, + "star_github" + ) + val starUri = "https://lexip.dev/hecate/source".toUri() + val starIntent = Intent(Intent.ACTION_VIEW, starUri) + try { + context.startActivity(starIntent) + } catch (_: ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, starUri)) + } + } + ) + + // 6) About + DropdownMenuItem( + text = { Text(stringResource(R.string.title_about)) }, + onClick = { + menuExpanded = false + AnalyticsLogger.logOverflowMenuItemClicked(context, "about") + val aboutUri = "https://lexip.dev/hecate/about".toUri() + val aboutIntent = Intent(Intent.ACTION_VIEW, aboutUri) + Toast.makeText( + context, + "v${BuildConfig.VERSION_NAME}", + Toast.LENGTH_SHORT + ).show() + try { + context.startActivity(aboutIntent) + } catch (_: ActivityNotFoundException) { + context.startActivity(Intent(Intent.ACTION_VIEW, aboutUri)) + } + onAboutClick() + } + ) + } + } +} From 7caaf9c57f307470072dd96302f187caa1aaa27c Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 18:37:41 +0100 Subject: [PATCH 13/23] fix: Pause sensor usage when the UI is in background * Fixes #61 --- .../lexip/hecate/ui/AdaptiveThemeViewModel.kt | 18 ++++++++--- .../java/dev/lexip/hecate/ui/MainActivity.kt | 31 +++++++++++++------ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt index 8fee39e..d69118a 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -110,6 +110,18 @@ class AdaptiveThemeViewModel( } } + fun startSensorsIfEnabled() { + if (_uiState.value.adaptiveThemeEnabled) { + startLightSensorListening() + startProximityListening() + } + } + + fun stopSensors() { + stopLightSensorListening() + stopProximityListening() + } + // Temporary variable for custom threshold private var customThresholdTemp: Float? = null @@ -124,11 +136,9 @@ class AdaptiveThemeViewModel( ) if (userPreferences.adaptiveThemeEnabled) { - startLightSensorListening() - startProximityListening() + startSensorsIfEnabled() } else { - stopLightSensorListening() - stopProximityListening() + stopSensors() } } } 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 461e890..1cbefc6 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt @@ -22,7 +22,6 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.viewmodel.compose.viewModel import dev.lexip.hecate.HecateApplication import dev.lexip.hecate.data.UserPreferencesRepository import dev.lexip.hecate.ui.theme.HecateTheme @@ -32,6 +31,7 @@ import dev.lexip.hecate.util.InstallSourceChecker class MainActivity : ComponentActivity() { private var inAppUpdateManager: InAppUpdateManager? = null + private lateinit var adaptiveThemeViewModel: AdaptiveThemeViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -58,15 +58,18 @@ class MainActivity : ComponentActivity() { } } - setContent { - val dataStore = (this.applicationContext as HecateApplication).userPreferencesDataStore - val adaptiveThemeViewModel: AdaptiveThemeViewModel = viewModel( - factory = AdaptiveThemeViewModelFactory( - this.application as HecateApplication, - UserPreferencesRepository(dataStore), - DarkThemeHandler(applicationContext) - ) + // Obtain a stable ViewModel instance + val dataStore = (this.applicationContext as HecateApplication).userPreferencesDataStore + adaptiveThemeViewModel = androidx.lifecycle.ViewModelProvider( + this, + AdaptiveThemeViewModelFactory( + this.application as HecateApplication, + UserPreferencesRepository(dataStore), + DarkThemeHandler(applicationContext) ) + )[AdaptiveThemeViewModel::class.java] + + setContent { val state by adaptiveThemeViewModel.uiState.collectAsState() HecateTheme { @@ -81,6 +84,16 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() + if (this::adaptiveThemeViewModel.isInitialized) { + adaptiveThemeViewModel.startSensorsIfEnabled() + } inAppUpdateManager?.resumeImmediateUpdateIfNeeded() } + + override fun onPause() { + if (this::adaptiveThemeViewModel.isInitialized) { + adaptiveThemeViewModel.stopSensors() + } + super.onPause() + } } From 55de8534bbe15afee550ccd70461a141dd47bcb8 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sat, 6 Dec 2025 18:58:58 +0100 Subject: [PATCH 14/23] refactor(ui): Increase hysteresis delay for device-covered checks --- app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d69118a..ca87ce2 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -95,7 +95,7 @@ class AdaptiveThemeViewModel( proximitySensorManager.startListening({ distance: Float -> val covered = distance < 5f if (covered != _uiState.value.isDeviceCovered) { - if (!covered) Thread.sleep(300) // Prevents UI flickering + if (covered) Thread.sleep(1000) // Prevents UI flickering _uiState.value = _uiState.value.copy(isDeviceCovered = covered) } }, sensorDelay = SensorManager.SENSOR_DELAY_UI) From 6c7e5c53264f1fcb3eebcf0bb378f51a996d63e6 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 16:53:54 +0100 Subject: [PATCH 15/23] feat(ui): Implement a setup card on the main screen * Setup entry point when the permission was not yet granted. * Implements #62 --- .../lexip/hecate/ui/AdaptiveThemeScreen.kt | 56 ++++++++++++++++--- .../lexip/hecate/ui/AdaptiveThemeViewModel.kt | 8 +++ app/src/main/res/values-de/strings.xml | 8 ++- app/src/main/res/values/strings.xml | 8 ++- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt index c5405d4..5f0ad66 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -148,6 +148,11 @@ fun AdaptiveThemeScreen( ) } ) { innerPadding -> + val hasWriteSecureSettingsPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_SECURE_SETTINGS + ) == PackageManager.PERMISSION_GRANTED + Column( modifier = Modifier .fillMaxSize() @@ -163,17 +168,54 @@ fun AdaptiveThemeScreen( style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 21.sp) ) + // Setup card shown when the required permission has not been granted yet + if (!hasWriteSecureSettingsPermission) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface + ), + 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)) + } + } + } + } + } + MainSwitchPreferenceCard( - text = stringResource(id = R.string.action_use_adaptive_theme), + text = stringResource( + id = R.string.action_use_adaptive_theme, + stringResource(id = R.string.app_name) + ), isChecked = uiState.adaptiveThemeEnabled, onCheckedChange = { checked -> - val hasPermission = ContextCompat.checkSelfPermission( - context, Manifest.permission.WRITE_SECURE_SETTINGS - ) == PackageManager.PERMISSION_GRANTED - adaptiveThemeViewModel.onServiceToggleRequested( checked, - hasPermission, + hasWriteSecureSettingsPermission, packageName ).also { wasToggled -> if (wasToggled) @@ -249,6 +291,7 @@ fun AdaptiveThemeScreen( } } } + Spacer(modifier = Modifier.padding(bottom = 4.dp)) } } @@ -268,4 +311,3 @@ fun AdaptiveThemeScreen( onDismiss = { showCustomDialog.value = false } ) } - 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 ca87ce2..5af4e37 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -89,6 +89,14 @@ class AdaptiveThemeViewModel( private val proximitySensorManager = ProximitySensorManager(application.applicationContext) private var isListeningToProximity = false + fun onSetupRequested(packageName: String) { + onServiceToggleRequested( + checked = true, + hasPermission = false, + packageName = packageName + ) + } + private fun startProximityListening() { if (isListeningToProximity) return isListeningToProximity = true diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3ab46a2..c57c38a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -11,10 +11,10 @@ Teilen Link teilen Dienst stoppen - Adaptive Theme verwenden + %1$s verwenden - Wechselt automatisch zwischen hellem und dunklem Modus basierend auf dem Umgebungslicht – für bessere Sichtbarkeit und Akkulaufzeit. Änderungen werden nur direkt nach Einschalten des Bildschirms vorgenommen. + Wechselt automatisch zwischen hellem und dunklem Modus basierend auf dem Umgebungslicht – für bessere Sichtbarkeit und Akkulaufzeit. Änderungen werden nur direkt nach Einschalten des Bildschirms vorgenommen und nu, wenn das Gerät nicht bedeckt ist. @@ -73,4 +73,8 @@ Warten auf USB-Verbindung … Die Berechtigung kann nur über ADB erteilt werden, wofür ein anderes Gerät mit einem Webbrowser oder ADB erforderlich ist. Warum wird ein anderes Gerät benötigt? + + Einrichtung abschließen + %1$s benötigt eine einmalige Einrichtung für die erforderliche Berechtigung. + Einrichtung abschließen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d47e13..29398e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,10 +14,10 @@ Share Share Link Stop Service - Use Adaptive Theme + Use %1$s - Automatically switches between Light and Dark mode based on ambient light for better visibility and battery life. Changes are only made right after the screen turns on. + Automatically switches between Light and Dark mode based on ambient light for better visibility and battery life. Changes are only made right after the screen turns on and only when the device is not covered. Adjusting the system theme based on ambient brightness. @@ -75,4 +75,8 @@ Waiting for USB connection The permission can only be granted via ADB, which requires another device with either a web browser or ADB installed. Why is another device required? + + Finish setup + %1$s needs a one-time setup to grant the required permission. + Finish Setup From b2902034ce79f777fb6a2f9c9f3363a6c08ca6a2 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 17:07:44 +0100 Subject: [PATCH 16/23] feat(ui): Add a safety statement to the setup wizard * Added to step 2. --- .../hecate/ui/setup/PermissionSetupSteps.kt | 24 +++++++++++++++++++ app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 28 insertions(+) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt index 92838c1..d96f7da 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt @@ -373,6 +373,30 @@ private fun ConnectionWhySection() { ) } } + + 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c57c38a..386f7e8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -73,6 +73,8 @@ Warten auf USB-Verbindung … Die Berechtigung kann nur über ADB erteilt werden, wofür ein anderes Gerät mit einem Webbrowser oder ADB erforderlich ist. Warum wird ein anderes Gerät benötigt? + Ist das sicher? + Ja. Es erlaubt der App nur, Einstellungen wie den Dunkelmodus zu ändern. Das ist absolut sicher und vollständig reversibel. Einrichtung abschließen %1$s benötigt eine einmalige Einrichtung für die erforderliche Berechtigung. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29398e9..b07d054 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,6 +75,8 @@ Waiting for USB connection The permission can only be granted via ADB, which requires another device with either a web browser or ADB installed. Why is another device required? + Is this safe? + Yes. It just allows the app to modify settings like the dark mode. This is absolutely safe and completely reversible. Finish setup %1$s needs a one-time setup to grant the required permission. From 82d0055c3fb3e52e5ba9e70f3e217bd9d7e01ce9 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 17:18:57 +0100 Subject: [PATCH 17/23] feat(ui): Allow to skip setup step 2 to see what step 3 is --- .../java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt | 7 +++---- app/src/main/res/values-de/strings.xml | 5 +++-- app/src/main/res/values/strings.xml | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt index d96f7da..272a528 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt @@ -337,14 +337,13 @@ internal fun ConnectUsbStep( OutlinedButton(onClick = onExit) { Text(text = stringResource(id = R.string.action_close)) } - Button( + OutlinedButton( onClick = { haptic.performHapticFeedback(HapticFeedbackType.ContextClick) onNext() - }, - enabled = isUsbConnected + } ) { - Text(text = stringResource(id = R.string.action_continue)) + Text(text = stringResource(id = R.string.action_skip)) } } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 386f7e8..6ce9d8d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -4,6 +4,7 @@ Abbrechen Schließen Weiter + Überspringen Kopieren Benachrichtigung ausblenden Fertig @@ -14,7 +15,7 @@ %1$s verwenden - Wechselt automatisch zwischen hellem und dunklem Modus basierend auf dem Umgebungslicht – für bessere Sichtbarkeit und Akkulaufzeit. Änderungen werden nur direkt nach Einschalten des Bildschirms vorgenommen und nu, wenn das Gerät nicht bedeckt ist. + Wechselt automatisch zwischen hellem und dunklem Modus basierend auf dem Umgebungslicht – für optimierte Lesbarkeit und Akkulaufzeit. Änderungen werden nur direkt nach Einschalten des Bildschirms vorgenommen und nu, wenn das Gerät nicht bedeckt ist. @@ -61,7 +62,7 @@ Mehrfach auf die Build-Nummer tippen, um die Entwickleroptionen zu aktivieren. Alternative für Experten Öffne diese Website auf einem anderen Gerät und folge den Anweisungen dort: - Berechtigung erteilen + Berechtigung mit dem anderen Gerät erteilen Falls ADB auf dem anderen Gerät installiert ist, kannst du dort stattdessen diesen Command ausführen: Berechtigung erfolgreich erteilt! Berechtigung noch nicht erteilt. Schließen Sie die Einrichtung auf dem anderen Gerät ab. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b07d054..3f7f365 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,7 @@ Cancel Close Continue + Skip Copy Hide Notification Finish @@ -63,7 +64,7 @@ Look for the build number and tap it 7 times to enable the developer settings. You\'ll see a message confirming it\'s enabled. Alternative for experts On your other device, open this website and follow the instructions: - Grant the permission + Grant the permission with the other device If you have ADB installed on your computer, you can run this command instead: Permission granted successfully! Permission not yet granted. Complete the setup on your other device. From 11090bd6b07061f81bd462665e9258da69dae6e8 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 18:00:33 +0100 Subject: [PATCH 18/23] refactor(service): Change the notification action to pause rather than stop/disable * The service will restart on next boot or app open when being paused from the notification action. --- .../services/BroadcastReceiverService.kt | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt index 5438d9f..e159020 100644 --- a/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt +++ b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.launch private const val TAG = "BroadcastReceiverService" private const val NOTIFICATION_CHANNEL_ID = "ForegroundServiceChannel" -private const val ACTION_STOP_SERVICE = "dev.lexip.hecate.action.STOP_SERVICE" +private const val ACTION_PAUSE_SERVICE = "dev.lexip.hecate.action.STOP_SERVICE" private var screenOnReceiver: ScreenOnReceiver? = null @@ -67,23 +67,10 @@ class BroadcastReceiverService : Service() { val dataStore = (this.applicationContext as HecateApplication).userPreferencesDataStore // Handle stop action from notification - if (intent?.action == ACTION_STOP_SERVICE) { - Log.i( - TAG, - "Disable action received from notification - disabling adaptive theme and stopping service..." - ) + if (intent?.action == ACTION_PAUSE_SERVICE) { + Log.i(TAG, "Pause action received from notification.") serviceScope.launch { - try { - val userPreferencesRepository = UserPreferencesRepository(dataStore) - userPreferencesRepository.updateAdaptiveThemeEnabled(false) - Log.i(TAG, "Adaptive theme disabled via notification action.") - AnalyticsLogger.logServiceDisabled( - applicationContext, - source = "notification_action" - ) - } catch (e: Exception) { - Log.e(TAG, "Failed to update adaptive theme preference", e) - } + Log.i(TAG, "Adaptive theme paused/killed via notification action.") stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } @@ -161,20 +148,20 @@ class BroadcastReceiverService : Service() { pendingIntent ).build() - // Create action to stop the service + // 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_STOP_SERVICE + action = ACTION_PAUSE_SERVICE } - val stopPendingIntent = PendingIntent.getService( + val pausePendingIntent = PendingIntent.getService( this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE ) - val stopAction = NotificationCompat.Action.Builder( + val pauseAction = NotificationCompat.Action.Builder( 0, - getString(R.string.action_stop_service), - stopPendingIntent + getString(R.string.action_pause_service), + pausePendingIntent ).build() // Build notification @@ -186,7 +173,7 @@ class BroadcastReceiverService : Service() { .setOnlyAlertOnce(true) .setContentIntent(pendingIntent) .addAction(disableAction) - .addAction(stopAction) + .addAction(pauseAction) .setOngoing(true) From 87542e252bb96015422db23d19445430e886e541 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 18:13:01 +0100 Subject: [PATCH 19/23] refactor(res): Optimize the app strings and translations --- .../hecate/ui/setup/PermissionSetupSteps.kt | 2 +- app/src/main/res/values-de/strings.xml | 65 +++++++++---------- app/src/main/res/values/strings.xml | 65 +++++++++---------- 3 files changed, 64 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt index 272a528..52b60e6 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt @@ -524,7 +524,7 @@ private fun WebsiteShareCard( modifier = Modifier.wrapContentWidth(), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp) ) { - Text(text = stringResource(id = R.string.action_share_setup_url)) + Text(text = stringResource(id = R.string.action_share_url)) } } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6ce9d8d..4f9a5e6 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,36 +1,34 @@ - - Abbrechen Schließen Weiter - Überspringen Kopieren Benachrichtigung ausblenden Fertig Festlegen Teilen - Link teilen - Dienst stoppen + Link teilen %1$s verwenden + Überspringen + Service pausieren - Wechselt automatisch zwischen hellem und dunklem Modus basierend auf dem Umgebungslicht – für optimierte Lesbarkeit und Akkulaufzeit. Änderungen werden nur direkt nach Einschalten des Bildschirms vorgenommen und nu, wenn das Gerät nicht bedeckt ist. + 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. - Systemtheme wird anhand der Umgebungshelligkeit angepasst. + Passt das Systemdesign an die Umgebungshelligkeit an. - Gerät bedeckt - Das Theme wird nur geändert, wenn das Gerät nicht bedeckt ist. + Sensor verdeckt + Theme-Änderungen sind pausiert, da der Lichtsensor verdeckt ist. - Ungültiger Lux-Wert. - Der Lux-Wert kann maximal 100.000 sein. - Der Lux-Wert darf nicht negativ sein. Keine E-Mail-App gefunden. + Ungültiger Helligkeitswert. + Wert darf nicht negativ sein. + Wert darf 100.000 nicht überschreiten. - Info + Über Helligkeitsschwelle Sprache ändern Aktuelle Helligkeit @@ -49,35 +47,34 @@ Lux-Wert - - Einstellungen öffnen + Entwickleroptionen öffnen Einstellungen öffnen - Um die Berechtigung zu erteilen, benötigen Sie ein weiteres Gerät (vorzugsweise einen Computer) mit Webbrowser. Verbinden Sie dieses Gerät per USB-Kabel damit. Mit einem anderen Gerät verbinden - In die Zwischenablage kopiert. - Tippen Sie einfach mehrmals auf die Build-Nummer, um die Entwickleroptionen freizuschalten, und aktivieren Sie dort USB-Debugging. + 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 - Entwickleroptionen aktiviert + Aktiviere USB-Debugging in den Entwickleroptionen. Entwickleroptionen aktivieren - Mehrfach auf die Build-Nummer tippen, um die Entwickleroptionen zu aktivieren. - Alternative für Experten - Öffne diese Website auf einem anderen Gerät und folge den Anweisungen dort: + Entwickleroptionen aktiviert + Finde die Build-Nummer und tippe 7-mal darauf, um die Entwickleroptionen zu aktivieren. + Alternative für Experten (ADB) Berechtigung mit dem anderen Gerät erteilen - Falls ADB auf dem anderen Gerät installiert ist, kannst du dort stattdessen diesen Command ausführen: - Berechtigung erfolgreich erteilt! - Berechtigung noch nicht erteilt. Schließen Sie die Einrichtung auf dem anderen Gerät ab. + Ö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: + 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 - USB-Debugging aktivieren - Warten auf USB-Verbindung … - Die Berechtigung kann nur über ADB erteilt werden, wofür ein anderes Gerät mit einem Webbrowser oder ADB erforderlich ist. - Warum wird ein anderes Gerät benötigt? + Aktiviere USB-Debugging + 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 reversibel. + Ja. Es erlaubt der App nur, Einstellungen wie den Dunkelmodus zu ändern. Das ist absolut sicher und vollständig rückgängig zu machen. - Einrichtung abschließen - %1$s benötigt eine einmalige Einrichtung für die erforderliche Berechtigung. - Einrichtung abschließen - + Einrichtung erforderlich + %1$s benötigt eine einmalige Einrichtung, um zu funktionieren. + Einrichtung starten + \ 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 3f7f365..ce28f28 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,34 +3,34 @@ lexip.dev/setup Star on GitHub - Cancel Close Continue - Skip Copy Hide Notification Finish Set Share - Share Link - Stop Service + Share Link + Skip + Pause Service Use %1$s - Automatically switches between Light and Dark mode based on ambient light for better visibility and battery life. Changes are only made right after the screen turns on and only when the device is not covered. + Automatically switches between Light and Dark mode based on ambient light. This saves battery and improves readability. The theme only changes right after you turn the screen on and only when the sensor is not covered. + Adjusting the system theme based on ambient brightness. - Device covered - System theme changes are only made when the device is not covered. + Sensor Covered + Theme changes are paused because the light sensor is covered. No email app found. - Invalid lux value. - Lux value cannot be negative. - Lux value cannot exceed 100,000. + Invalid brightness value. + Value cannot be negative. + Value cannot exceed 100,000. About Brightness Threshold @@ -38,7 +38,7 @@ Current Brightness Custom Threshold More - Background Activity + Theme Switching Service Send Feedback Bright @@ -51,35 +51,34 @@ Lux value - - Open Settings + Open Developer Options Open Settings - To grant the permission, you\'ll need another device (preferably a computer) with a web browser. Connect this device to it using a USB cable. - Connect to another device - Command copied to clipboard. - Tap the build number several times to unlock Developer options, then enable USB debugging there. + 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 - Developer options enabled + Enable USB-Debugging in the developer options. Enable developer options - Look for the build number and tap it 7 times to enable the developer settings. You\'ll see a message confirming it\'s enabled. - Alternative for experts - On your other device, open this website and follow the instructions: - Grant the permission with the other device + Developer options enabled + Find the Build number and tap it 7 times to enable developer options. + Alternative for experts (ADB) + 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: - Permission granted successfully! - Permission not yet granted. Complete the setup on your other device. + 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 - The permission can only be granted via ADB, which requires another device with either a web browser or ADB installed. + Enable USB Debugging + USB Debugging enabled + Enable USB Debugging + 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. - Finish setup - %1$s needs a one-time setup to grant the required permission. - Finish Setup - + Setup Required + %1$s requires a one-time setup to function. + Start Setup + \ No newline at end of file From 4a22561926386437e789c38c5e79d0737262ba56 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 18:25:09 +0100 Subject: [PATCH 20/23] chore(ui): Hide the pause action in the service notification for now --- .../services/BroadcastReceiverService.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt index 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) From 4b90430596a6fb5663588c16ab042989f7d04300 Mon Sep 17 00:00:00 2001 From: xLexip Date: Sun, 7 Dec 2025 18:05:11 +0100 Subject: [PATCH 21/23] fix: Always restart the service onResume if adaptive theme is enabled * It may have been paused by the user in the meantime (notification, foreground service menu, etc.) --- .../java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt | 2 ++ app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt index 5af4e37..560808f 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeViewModel.kt @@ -61,6 +61,8 @@ class AdaptiveThemeViewModel( private val _uiState = MutableStateFlow(AdaptiveThemeUiState()) val uiState: StateFlow = _uiState.asStateFlow() + fun isAdaptiveThemeEnabled(): Boolean = _uiState.value.adaptiveThemeEnabled + // One-shot UI events private val _uiEvents = MutableSharedFlow( replay = 0, diff --git a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt index 1cbefc6..9e085fb 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import dev.lexip.hecate.HecateApplication import dev.lexip.hecate.data.UserPreferencesRepository +import dev.lexip.hecate.services.BroadcastReceiverService import dev.lexip.hecate.ui.theme.HecateTheme import dev.lexip.hecate.util.DarkThemeHandler import dev.lexip.hecate.util.InstallSourceChecker @@ -86,6 +87,12 @@ class MainActivity : ComponentActivity() { super.onResume() if (this::adaptiveThemeViewModel.isInitialized) { adaptiveThemeViewModel.startSensorsIfEnabled() + + // Always restart the service (it may have been paused in the meantime) + if (adaptiveThemeViewModel.isAdaptiveThemeEnabled()) { + val intent = android.content.Intent(this, BroadcastReceiverService::class.java) + androidx.core.content.ContextCompat.startForegroundService(this, intent) + } } inAppUpdateManager?.resumeImmediateUpdateIfNeeded() } From 47e83de0a619698980c4cc2ecff8373cc8003047 Mon Sep 17 00:00:00 2001 From: xLexip Date: Mon, 8 Dec 2025 11:26:28 +0100 Subject: [PATCH 22/23] chore: Implement flexible update prompts and easy prompt intrusiveness * Flexible update prompts after 1 day * Immediate update prompts after 3 days --- .../dev/lexip/hecate/ui/InAppUpdateManager.kt | 83 ++++++++++++++++++- .../java/dev/lexip/hecate/ui/MainActivity.kt | 9 +- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt index 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 9e085fb..a86dc7e 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt @@ -81,20 +81,23 @@ class MainActivity : ComponentActivity() { } inAppUpdateManager?.checkForImmediateUpdate() + inAppUpdateManager?.checkForFlexibleUpdate() } override fun onResume() { super.onResume() + + inAppUpdateManager?.resumeImmediateUpdateIfNeeded() + inAppUpdateManager?.resumeFlexibleUpdateIfNeeded() + + // Always restart the service (it may have been paused in the meantime) if (this::adaptiveThemeViewModel.isInitialized) { adaptiveThemeViewModel.startSensorsIfEnabled() - - // Always restart the service (it may have been paused in the meantime) if (adaptiveThemeViewModel.isAdaptiveThemeEnabled()) { val intent = android.content.Intent(this, BroadcastReceiverService::class.java) androidx.core.content.ContextCompat.startForegroundService(this, intent) } } - inAppUpdateManager?.resumeImmediateUpdateIfNeeded() } override fun onPause() { From dad261d293f3b37ba7953de68a57dd0c06140b3c Mon Sep 17 00:00:00 2001 From: xLexip Date: Mon, 8 Dec 2025 12:18:01 +0100 Subject: [PATCH 23/23] feat(ui): Show the live lux measurement when the service is enabled --- .../preferences/ProgressDetailCard.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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..f32a54e 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,10 +21,13 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp @Composable @@ -63,6 +66,28 @@ fun ProgressDetailCard( activeIndex = activeIndex, enabled = enabled ) + + // Show live lux measurement + if (enabled) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Live Measurement", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + val liveLuxRounded = currentLux.toInt() + Text( + text = "$liveLuxRounded lx", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End + ) + } + } } } }