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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 489edef..181296d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,10 +23,16 @@ + + + @@ -66,11 +72,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}") + } } } 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/services/BroadcastReceiverService.kt b/app/src/main/java/dev/lexip/hecate/services/BroadcastReceiverService.kt index 5438d9f..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 @@ -39,7 +38,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 +66,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,22 +147,6 @@ class BroadcastReceiverService : Service() { pendingIntent ).build() - // Create action to stop the service - val stopIntent = Intent(this, BroadcastReceiverService::class.java).apply { - action = ACTION_STOP_SERVICE - } - val stopPendingIntent = PendingIntent.getService( - this, - 0, - stopIntent, - PendingIntent.FLAG_IMMUTABLE - ) - val stopAction = NotificationCompat.Action.Builder( - 0, - getString(R.string.action_stop_service), - stopPendingIntent - ).build() - // Build notification val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentTitle(getString(R.string.app_name)) @@ -186,7 +156,6 @@ class BroadcastReceiverService : Service() { .setOnlyAlertOnce(true) .setContentIntent(pendingIntent) .addAction(disableAction) - .addAction(stopAction) .setOngoing(true) diff --git a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt index 38c7b60..5f0ad66 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/AdaptiveThemeScreen.kt @@ -13,26 +13,21 @@ 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 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.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults 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 @@ -45,50 +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.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.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 +private val horizontalOffsetPadding = 8.dp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -98,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 @@ -151,7 +124,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( @@ -164,131 +137,29 @@ 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) - - } - ) - - // 3) Beta Feedback (only on beta builds) - if (BuildConfig.VERSION_NAME.contains("-beta")) { - 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) - } - ) - } - - // 4) 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 ) } ) { innerPadding -> + val hasWriteSecureSettingsPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_SECURE_SETTINGS + ) == PackageManager.PERMISSION_GRANTED + Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) .padding(horizontal = ScreenHorizontalMargin) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(32.dp) + verticalArrangement = Arrangement.spacedBy(24.dp) ) { Text( @@ -296,17 +167,55 @@ fun AdaptiveThemeScreen( text = stringResource(id = R.string.description_adaptive_theme), 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) @@ -357,202 +266,38 @@ 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? + // 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) ) { - 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 + 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 + ) } - - // 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) } + Spacer(modifier = Modifier.padding(bottom = 4.dp)) } + } - 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() - } - } - ) + // Show permission wizard if needed + if (internalUiState.showPermissionWizard) { + PermissionSetupHost(viewModel = adaptiveThemeViewModel) return } @@ -565,4 +310,4 @@ fun AdaptiveThemeScreen( }, onDismiss = { showCustomDialog.value = false } ) -} \ 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 67559d2..560808f 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 @@ -25,6 +26,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 +47,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( @@ -58,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, @@ -82,21 +87,69 @@ class AdaptiveThemeViewModel( _currentSensorLux.value = lux } + // Proximity Sensor + 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 + proximitySensorManager.startListening({ distance: Float -> + val covered = distance < 5f + if (covered != _uiState.value.isDeviceCovered) { + if (covered) Thread.sleep(1000) // Prevents UI flickering + _uiState.value = _uiState.value.copy(isDeviceCovered = covered) + } + }, sensorDelay = SensorManager.SENSOR_DELAY_UI) + } + + private fun stopProximityListening() { + if (!isListeningToProximity) return + isListeningToProximity = false + proximitySensorManager.stopListening() + if (_uiState.value.isDeviceCovered) { + _uiState.value = _uiState.value.copy(isDeviceCovered = false) + } + } + + fun startSensorsIfEnabled() { + if (_uiState.value.adaptiveThemeEnabled) { + startLightSensorListening() + startProximityListening() + } + } + + fun stopSensors() { + stopLightSensorListening() + stopProximityListening() + } + // 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) { + startSensorsIfEnabled() + } else { + stopSensors() + } } } } @@ -104,11 +157,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() { @@ -119,6 +172,7 @@ class AdaptiveThemeViewModel( override fun onCleared() { stopLightSensorListening() + stopProximityListening() super.onCleared() } 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..616cc6f 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/InAppUpdateManager.kt @@ -24,25 +24,36 @@ 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 +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) { - 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") + AnalyticsLogger.logInAppUpdateInstalled(activity) } Activity.RESULT_CANCELED -> { @@ -70,29 +81,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 +114,6 @@ class InAppUpdateManager(activity: ComponentActivity) { onError(t) } } else { - Log.d( - TAG, - "No eligible immediate update. availability=$availability, immediateAllowed=$isImmediateAllowed" - ) onNoUpdate() } } @@ -117,19 +123,69 @@ 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 + } 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,15 +195,43 @@ 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 -> 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 bba79de..a86dc7e 100644 --- a/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt +++ b/app/src/main/java/dev/lexip/hecate/ui/MainActivity.kt @@ -22,18 +22,22 @@ 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.services.BroadcastReceiverService 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 + private lateinit var adaptiveThemeViewModel: AdaptiveThemeViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + installSplashScreen() + enableEdgeToEdge() // Catch mysterious unsupported SDK versions despite minSDK 31 @SuppressLint("ObsoleteSdkInt") @@ -47,21 +51,26 @@ 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 - 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 { @@ -71,13 +80,30 @@ class MainActivity : ComponentActivity() { } } - inAppUpdateManager.checkForImmediateUpdate() + inAppUpdateManager?.checkForImmediateUpdate() + inAppUpdateManager?.checkForFlexibleUpdate() } override fun onResume() { super.onResume() - if (::inAppUpdateManager.isInitialized) { - inAppUpdateManager.resumeImmediateUpdateIfNeeded() + + inAppUpdateManager?.resumeImmediateUpdateIfNeeded() + inAppUpdateManager?.resumeFlexibleUpdateIfNeeded() + + // Always restart the service (it may have been paused in the meantime) + if (this::adaptiveThemeViewModel.isInitialized) { + adaptiveThemeViewModel.startSensorsIfEnabled() + if (adaptiveThemeViewModel.isAdaptiveThemeEnabled()) { + val intent = android.content.Intent(this, BroadcastReceiverService::class.java) + androidx.core.content.ContextCompat.startForegroundService(this, intent) + } + } + } + + override fun onPause() { + if (this::adaptiveThemeViewModel.isInitialized) { + adaptiveThemeViewModel.stopSensors() } + super.onPause() } } 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() + } + ) + } + } +} 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 + ) + } + } } } } 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/PermissionSetupSteps.kt b/app/src/main/java/dev/lexip/hecate/ui/setup/PermissionSetupSteps.kt index 92838c1..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 @@ -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)) } } } @@ -373,6 +372,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 @@ -501,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/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) +} + 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 + } + } +} + 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) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 91a8285..4f9a5e6 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,6 +1,4 @@ - - Abbrechen Schließen Weiter @@ -9,24 +7,28 @@ Fertig Festlegen Teilen - Link teilen - Dienst stoppen - Adaptive Theme verwenden + Link teilen + %1$s verwenden + Überspringen + Service pausieren - 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 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. - Ungültiger Lux-Wert. - Der Lux-Wert kann maximal 100.000 sein. - Der Lux-Wert darf nicht negativ sein. + Sensor verdeckt + Theme-Änderungen sind pausiert, da der Lichtsensor verdeckt ist. + 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 @@ -45,29 +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: - Berechtigung 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. + 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 + Ö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 rückgängig zu machen. + + 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 66d7063..ce28f28 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,8 +1,8 @@ Adaptive Theme lexip.dev/setup + Star on GitHub - Cancel Close Continue @@ -11,21 +11,26 @@ Finish Set Share - Share Link - Stop Service - Use Adaptive Theme + 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. 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. 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. + 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 @@ -33,7 +38,7 @@ Current Brightness Custom Threshold More - Background Activity + Theme Switching Service Send Feedback Bright @@ -46,29 +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 + 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. + + Setup Required + %1$s requires a one-time setup to function. + Start Setup + \ No newline at end of file