From 5f119a4438c67d967afc52e71fe64b93279a9908 Mon Sep 17 00:00:00 2001 From: Nello Carotenuto Date: Tue, 1 Sep 2026 23:37:01 +0200 Subject: [PATCH 1/6] Add widget ripple effect toggle and layout adjustments --- .../main/java/com/ah/taplock/MainActivity.kt | 23 +++++ .../com/ah/taplock/TapLockWidgetProvider.kt | 4 +- .../res/layout/widget_layout_no_ripple.xml | 18 ++++ app/src/main/res/values-es/strings.xml | 90 +++++++++++++------ app/src/main/res/values/strings.xml | 79 +++++++++++----- 5 files changed, 161 insertions(+), 53 deletions(-) create mode 100644 app/src/main/res/layout/widget_layout_no_ripple.xml diff --git a/app/src/main/java/com/ah/taplock/MainActivity.kt b/app/src/main/java/com/ah/taplock/MainActivity.kt index f6318a4..0135e75 100644 --- a/app/src/main/java/com/ah/taplock/MainActivity.kt +++ b/app/src/main/java/com/ah/taplock/MainActivity.kt @@ -129,6 +129,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val sharedPrefName = stringResource(R.string.shared_pref_name) val doubleTapTimeoutKey = stringResource(R.string.double_tap_timeout) val showWidgetIconKey = stringResource(R.string.show_widget_icon) + val widgetRippleEnabledKey = stringResource(R.string.widget_ripple_enabled) val widgetStyleKey = stringResource(R.string.widget_style) val vibrateOnLockKey = stringResource(R.string.vibrate_on_lock) val statusBarModeKey = stringResource(R.string.status_bar_mode) @@ -177,6 +178,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { var timeoutValue by remember { mutableFloatStateOf(300f) } var showIcon by remember { mutableStateOf(false) } + var widgetRippleEnabled by remember { mutableStateOf(true) } var widgetStyle by remember { mutableStateOf(TapLockWidgetStyle.default) } var vibrateOnLock by remember { mutableStateOf(true) } var vibrationPattern by remember { mutableStateOf(VibrationPattern.MEDIUM) } @@ -421,6 +423,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val prefs = context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) timeoutValue = prefs.getInt(doubleTapTimeoutKey, 300).toFloat() showIcon = prefs.getBoolean(showWidgetIconKey, false) + widgetRippleEnabled = prefs.getBoolean(widgetRippleEnabledKey, true) widgetStyle = TapLockWidgetStyle.fromStored(prefs.getString(widgetStyleKey, null)) vibrateOnLock = prefs.getBoolean(vibrateOnLockKey, true) vibrationPattern = VibrationHelper.fromPrefs(context) @@ -626,6 +629,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val prefs = context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) lockCount = prefs.getInt(lockCountKey, 0) showIcon = prefs.getBoolean(showWidgetIconKey, false) + widgetRippleEnabled = prefs.getBoolean(widgetRippleEnabledKey, true) widgetStyle = TapLockWidgetStyle.fromStored(prefs.getString(widgetStyleKey, null)) leftEdgeMode = TapZoneMode.fromStored(prefs.getString(leftEdgeModeKey, null)) rightEdgeMode = TapZoneMode.fromStored(prefs.getString(rightEdgeModeKey, null)) @@ -988,6 +992,25 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { ) } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.widget_ripple_label)) + Switch( + checked = widgetRippleEnabled, + onCheckedChange = { isChecked -> + widgetRippleEnabled = isChecked + context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) + .edit { putBoolean(widgetRippleEnabledKey, isChecked) } + refreshWidgets() + }, + enabled = isAccessibilityEnabled, + modifier = Modifier.testTag("switch_widget_ripple") + ) + } + Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) diff --git a/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt b/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt index 6c968d0..b61dfa0 100644 --- a/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt +++ b/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt @@ -72,11 +72,13 @@ class TapLockWidgetProvider : AppWidgetProvider() { ) { val prefs = context.getSharedPreferences(context.getString(R.string.shared_pref_name), Context.MODE_PRIVATE) val showIcon = prefs.getBoolean(context.getString(R.string.show_widget_icon), false) + val rippleEnabled = prefs.getBoolean(context.getString(R.string.widget_ripple_enabled), true) val widgetStyle = TapLockWidgetStyle.fromStored( prefs.getString(context.getString(R.string.widget_style), null) ) - val views = RemoteViews(context.packageName, R.layout.widget_layout).apply { + val layoutResId = if (rippleEnabled) R.layout.widget_layout else R.layout.widget_layout_no_ripple + val views = RemoteViews(context.packageName, layoutResId).apply { setOnClickPendingIntent( R.id.widget_container, getPendingSelfIntent(context, appWidgetId) diff --git a/app/src/main/res/layout/widget_layout_no_ripple.xml b/app/src/main/res/layout/widget_layout_no_ripple.xml new file mode 100644 index 0000000..cf274de --- /dev/null +++ b/app/src/main/res/layout/widget_layout_no_ripple.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 002dfc1..a77fdb9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,9 +1,12 @@ - TapLock - Toca dos veces para bloquear la pantalla + TapLock - Toca dos veces para bloquear la + pantalla TapLock Mostrar icono del widget + Efecto de onda del widget Estilo del widget - Elige un aspecto más claro para la pantalla de inicio sin cambiar cómo funciona el widget. + Elige un aspecto más claro para la pantalla de inicio + sin cambiar cómo funciona el widget. Transparente Cristal Sólido @@ -12,37 +15,49 @@ Restablecer predeterminado Icono personalizado actualizado Icono personalizado restablecido - El servicio de accesibilidad de TapLock es necesario para poder apagar la pantalla. - Con esta app, puedes crear un widget transparente redimensionable para la pantalla de inicio. Toca dos veces el widget para bloquear el teléfono al instante. Se requieren permisos de accesibilidad para que funcione. - Esta app no recopila información personal ni accede a tus datos. Para mayor transparencia, el código fuente está disponible en el enlace siguiente: - Para añadir el widget, ve a la pantalla de inicio, mantén pulsado un espacio vacío y busca el widget de TapLock. + El servicio de accesibilidad de TapLock es + necesario para poder apagar la pantalla. + Con esta app, puedes crear un widget transparente + redimensionable para la pantalla de inicio. Toca dos veces el widget para bloquear el + teléfono al instante. Se requieren permisos de accesibilidad para que funcione. + Esta app no recopila información personal ni accede a tus + datos. Para mayor transparencia, el código fuente está disponible en el enlace siguiente: + Para añadir el widget, ve a la pantalla de inicio, + mantén pulsado un espacio vacío y busca el widget de TapLock. Permisos requeridos Servicio de accesibilidad Acceso rápido Configuración de widget, mosaico y botón flotante. Widget de pantalla de inicio Activadores ambientales - Gestos y regiones de pantalla que pueden bloquear el teléfono mientras lo usas. + Gestos y regiones de pantalla que pueden bloquear el + teléfono mientras lo usas. Comportamiento Tiempo, vibración y controles de prueba. Reglas de apps Activar Activado Permiso de accesibilidad requerido - TapLock necesita el servicio de accesibilidad para detectar toques dobles y bloquear la pantalla. TapLock no recopila ningún otro dato ni interacción que tengas con el dispositivo. + TapLock necesita el servicio de + accesibilidad para detectar toques dobles y bloquear la pantalla. TapLock no recopila ningún + otro dato ni interacción que tengas con el dispositivo. Aceptar Ahora no Bloquear Bloqueando pantalla… Permiso de accesibilidad requerido Vibrar al bloquear - Si el widget deja de funcionar después de un tiempo, prueba a desactivar la optimización de batería para esta app. Esto permite que el servicio de accesibilidad se ejecute de forma más fiable en segundo plano. + Si el widget deja de funcionar después de un + tiempo, prueba a desactivar la optimización de batería para esta app. Esto permite que el + servicio de accesibilidad se ejecute de forma más fiable en segundo plano. Barra de estado Toca la barra de estado para bloquear la pantalla. Solo zona de notch/cámara - Experimental: limita los toques en la barra de estado a la zona de notch o cámara cuando Android la informa. + Experimental: limita los toques en la + barra de estado a la zona de notch o cámara cuando Android la informa. Pantalla de bloqueo - Cuando la pantalla está bloqueada, toca dentro del área configurada para bloquear. + Cuando la pantalla está bloqueada, toca dentro del + área configurada para bloquear. Modo de toque Desactivado Doble toque @@ -58,19 +73,28 @@ Ligera Media Fuerte - Activa el servicio de accesibilidad para usar estas funciones + Activa el servicio de accesibilidad para usar estas + funciones Protección avanzada activada - Este dispositivo tiene activada la Protección avanzada de Android, que bloquea todos los servicios de accesibilidad de terceros. TapLock necesita un servicio de accesibilidad para bloquear la pantalla, por lo que no puede funcionar mientras la Protección avanzada esté activada. Para usar TapLock, desactiva la Protección avanzada en Ajustes > Seguridad y privacidad. + Este dispositivo tiene activada la Protección + avanzada de Android, que bloquea todos los servicios de accesibilidad de terceros. TapLock + necesita un servicio de accesibilidad para bloquear la pantalla, por lo que no puede + funcionar mientras la Protección avanzada esté activada. Para usar TapLock, desactiva la + Protección avanzada en Ajustes > Seguridad y privacidad. Abrir ajustes de seguridad Bienvenido a TapLock - Bloquea la pantalla al instante con un doble toque. Vamos a configurarlo en unos pocos pasos. + Bloquea la pantalla al instante con un doble toque. Vamos + a configurarlo en unos pocos pasos. Activar accesibilidad - TapLock necesita el servicio de accesibilidad para detectar toques dobles y bloquear la pantalla. No se recopila ningún otro dato. + TapLock necesita el servicio de accesibilidad para + detectar toques dobles y bloquear la pantalla. No se recopila ningún otro dato. Añadir el widget - Ve a la pantalla de inicio, mantén pulsado un espacio vacío y añade el widget de TapLock. Tócalo dos veces para bloquear. + Ve a la pantalla de inicio, mantén pulsado un espacio + vacío y añade el widget de TapLock. Tócalo dos veces para bloquear. Todo listo - TapLock está listo para usarse. Puedes personalizar los ajustes en cualquier momento desde esta pantalla. + TapLock está listo para usarse. Puedes personalizar los + ajustes en cualquier momento desde esta pantalla. Siguiente Abrir ajustes Listo @@ -90,7 +114,8 @@ Añadir widget a la pantalla de inicio Añadir otro widget Vista previa del widget - Tu launcher no permite fijar widgets desde apps. Añade TapLock desde el selector de widgets de la pantalla de inicio. + Tu launcher no permite fijar widgets desde apps. + Añade TapLock desde el selector de widgets de la pantalla de inicio. Aún no se ha añadido ningún widget a la pantalla de inicio. %d widget en tu pantalla de inicio @@ -98,23 +123,30 @@ %d widgets en tu pantalla de inicio Mosaico de ajustes rápidos - Añade TapLock a Ajustes rápidos para una acción nativa de bloqueo con un toque. + Añade TapLock a Ajustes rápidos para una acción + nativa de bloqueo con un toque. Añadir mosaico Añadido a Ajustes rápidos Aún no añadido - Android 12 y versiones anteriores: añade TapLock desde la pantalla de edición de Ajustes rápidos. + Android 12 y versiones anteriores: añade TapLock + desde la pantalla de edición de Ajustes rápidos. Mosaico de TapLock añadido a Ajustes rápidos - El mosaico de TapLock ya está en Ajustes rápidos + El mosaico de TapLock ya está en Ajustes + rápidos El mosaico de TapLock no se añadió - No se pudo añadir el mosaico de Ajustes rápidos ahora - Añade TapLock desde Ajustes rápidos en esta versión de Android. + No se pudo añadir el mosaico de Ajustes rápidos + ahora + Añade TapLock desde Ajustes rápidos en esta + versión de Android. Zona de toque en pantalla de bloqueo: %d%% Desplazamiento superior: %d%% Vista previa del área activa Resaltar área activa Zonas de bloqueo en bordes y esquinas - Toca un borde o esquina activado para bloquear. Deslizar hacia dentro desde un borde activado sigue haciendo Atrás. - Estos ajustes se aplican tanto en vertical como en horizontal. + Toca un borde o esquina activado para bloquear. Deslizar + hacia dentro desde un borde activado sigue haciendo Atrás. + Estos ajustes se aplican tanto en vertical como en + horizontal. Bordes de pantalla Borde izquierdo Borde derecho @@ -152,9 +184,11 @@ Bloquear ahora Bloquear Botón flotante de bloqueo - Muestra un botón flotante sobre otras apps para bloquear con un solo toque. + Muestra un botón flotante sobre otras apps para + bloquear con un solo toque. Tamaño del botón: %ddp Opacidad del botón: %d%% Vista previa del botón flotante - Arrástralo para cambiarlo de posición. Se ajusta al borde de pantalla más cercano. - + Arrástralo para cambiarlo de posición. Se ajusta al borde de + pantalla más cercano. + \ 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 4c221a9..17d9fbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,9 +5,12 @@ double_tap_timeout show_widget_icon Show Widget Icon + widget_ripple_enabled + Widget Ripple Effect widget_style Widget Style - Pick a clearer home screen look without changing how the widget works. + Pick a clearer home screen look without changing how the + widget works. Transparent Glass Solid @@ -16,10 +19,15 @@ Reset Default Custom icon updated Custom icon reset - The TapLock accessibility service is necessary to be able to power off the screen. - With this app, you can create a resizable transparent widget for your home screen. Double-tap the widget to lock your phone instantly! Accessibility permissions are required for this to work. - This app collects no personal information and does not access your data. For transparency, the source code is available via the link below: - To add the widget, please go to your home screen, long-press on empty space, and find the TapLock widget. + The TapLock accessibility service is necessary + to be able to power off the screen. + With this app, you can create a resizable transparent + widget for your home screen. Double-tap the widget to lock your phone instantly! + Accessibility permissions are required for this to work. + This app collects no personal information and does not + access your data. For transparency, the source code is available via the link below: + To add the widget, please go to your home screen, + long-press on empty space, and find the TapLock widget. https://github.com/modelorona/TapLock Required Permissions Accessibility Service @@ -27,14 +35,17 @@ Widget, tile, and floating button setup. Home Screen Widget Ambient Triggers - Gestures and screen regions that can lock the phone while you use it. + Gestures and screen regions that can lock the phone + while you use it. Behavior Timing, vibration, and test controls. App Rules Enable Enabled Accessibility Permission Required - TapLock needs accessibility services to detect double taps and lock the screen. TapLock does not collect any other data or interaction that you have with your device. + TapLock needs accessibility services to + detect double taps and lock the screen. TapLock does not collect any other data or + interaction that you have with your device. Agree Not now Lock @@ -42,16 +53,20 @@ Accessibility permission required vibrate_on_lock Vibrate on Lock - If the widget stops working after a while, try disabling battery optimization for this app. This allows the accessibility service to run more reliably in the background. + If the widget stops working after a while, try + disabling battery optimization for this app. This allows the accessibility service to run + more reliably in the background. status_bar_mode Status Bar Tap the status bar to lock the screen. status_bar_camera_area_only Notch/Camera Area Only - Experimental: limit status bar taps to the notch or camera area when Android reports it. + Experimental: limit status bar taps to + the notch or camera area when Android reports it. lock_screen_mode Lock Screen - When the screen is locked, tap inside the configured area to lock. + When the screen is locked, tap inside the configured + area to lock. left_edge_mode right_edge_mode top_left_corner_mode @@ -68,7 +83,8 @@ corner_zone_size_dp edge_zone_coverage_percent edge_zone_top_offset_percent - edge_zone_bottom_offset_percent + + edge_zone_bottom_offset_percent excluded_apps last_foreground_package @@ -89,18 +105,26 @@ Enable accessibility service to use these features Advanced Protection is on - This device has Android\'s Advanced Protection turned on, which blocks all third-party accessibility services. TapLock relies on an accessibility service to lock the screen, so it can\'t work while Advanced Protection is enabled. To use TapLock, turn off Advanced Protection in Settings > Security & privacy. + This device has Android\'s Advanced Protection + turned on, which blocks all third-party accessibility services. TapLock relies on an + accessibility service to lock the screen, so it can\'t work while Advanced Protection is + enabled. To use TapLock, turn off Advanced Protection in Settings > Security & + privacy. Open Security settings has_completed_onboarding Welcome to TapLock - Lock your screen instantly with a double-tap. Let\'s get you set up in a few quick steps. + Lock your screen instantly with a double-tap. Let\'s get + you set up in a few quick steps. Enable Accessibility - TapLock needs the accessibility service to detect double-taps and lock your screen. No other data is collected. + TapLock needs the accessibility service to detect + double-taps and lock your screen. No other data is collected. Add the Widget - Go to your home screen, long-press on empty space, and add the TapLock widget. Double-tap it to lock! + Go to your home screen, long-press on empty space, and add + the TapLock widget. Double-tap it to lock! You\'re All Set! - TapLock is ready to use. You can customise settings any time from this screen. + TapLock is ready to use. You can customise settings any time + from this screen. Next Open Settings Done @@ -125,7 +149,8 @@ Add Widget to Home Screen Add Another Widget Widget Preview - Your launcher doesn\'t support pinning widgets from apps. Add TapLock from the home screen widget picker. + Your launcher doesn\'t support pinning widgets + from apps. Add TapLock from the home screen widget picker. No home screen widget added yet. %d widget on your home screen @@ -133,16 +158,19 @@ quick_settings_tile_added Quick Settings Tile - Add TapLock to Quick Settings for a native one-tap lock action. + Add TapLock to Quick Settings for a native + one-tap lock action. Add Tile Added to Quick Settings Not added yet - Android 12 and lower: add TapLock from the Quick Settings edit screen. + Android 12 and lower: add TapLock from the Quick + Settings edit screen. TapLock tile added to Quick Settings TapLock tile is already in Quick Settings TapLock tile was not added Couldn\'t add the Quick Settings tile right now - Add TapLock from the Quick Settings edit screen on this Android version. + Add TapLock from the Quick Settings edit screen + on this Android version. lock_zone_percent lock_zone_top_offset_percent Lock screen tap zone: %d%% @@ -150,7 +178,8 @@ Active area preview Highlight active area Screen Edge And Corner Lock Zones - Tap an enabled screen edge or corner to lock. Swiping inward from an enabled screen edge still goes Back. + Tap an enabled screen edge or corner to lock. Swiping + inward from an enabled screen edge still goes Back. These settings apply in both portrait and landscape. Screen Edges Left Screen Edge @@ -189,13 +218,15 @@ floating_button_enabled floating_button_size_dp - floating_button_opacity_percent + + floating_button_opacity_percent floating_button_position_x floating_button_position_y Floating Lock Button - Show a floating button over other apps to lock with a single tap. + Show a floating button over other apps to lock with a + single tap. Button size: %ddp Button opacity: %d%% Floating button preview Drag it to reposition. It snaps to the nearest screen edge. - + \ No newline at end of file From 392adb19f00bbc845f1e1d2410e8ce8458150c46 Mon Sep 17 00:00:00 2001 From: Nello Carotenuto Date: Tue, 1 Sep 2026 23:54:20 +0200 Subject: [PATCH 2/6] Implement root-based locking method and enhance accessibility fallback --- .../main/java/com/ah/taplock/MainActivity.kt | 129 +++++++++++++++++- app/src/main/java/com/ah/taplock/RootLock.kt | 126 +++++++++++++++++ .../ah/taplock/TapLockAccessibilityService.kt | 10 ++ .../java/com/ah/taplock/TapLockFeedback.kt | 4 + .../java/com/ah/taplock/TapLockTileService.kt | 14 +- .../com/ah/taplock/TapLockWidgetProvider.kt | 5 + app/src/main/res/values-es/strings.xml | 19 +++ app/src/main/res/values/strings.xml | 22 +++ 8 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/ah/taplock/RootLock.kt diff --git a/app/src/main/java/com/ah/taplock/MainActivity.kt b/app/src/main/java/com/ah/taplock/MainActivity.kt index 0135e75..5e2fd2b 100644 --- a/app/src/main/java/com/ah/taplock/MainActivity.kt +++ b/app/src/main/java/com/ah/taplock/MainActivity.kt @@ -108,7 +108,12 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (intent?.action == "com.ah.taplock.LOCK_NOW") { - TapLockAccessibilityService.instance?.lockScreen() + val service = TapLockAccessibilityService.instance + if (service != null) { + service.lockScreen() + } else if (RootLock.isRootLockEnabled(this)) { + RootLock.performRootLock(this) + } finish() return } @@ -151,6 +156,9 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val customIconResetMsg = stringResource(R.string.custom_icon_reset) val hasSeenInfoKey = stringResource(R.string.has_seen_info) val hasCompletedOnboardingKey = stringResource(R.string.has_completed_onboarding) + val lockMethodKey = stringResource(R.string.lock_method) + val rootModePromptShownKey = stringResource(R.string.root_mode_prompt_shown) + val rootAccessDeniedMsg = stringResource(R.string.root_access_denied) val lockDelayMsKey = stringResource(R.string.lock_delay_ms) val lockCountKey = stringResource(R.string.lock_count) val lockZonePercentKey = stringResource(R.string.lock_zone_percent) @@ -176,6 +184,11 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName)) } + var isDeviceRooted by remember { mutableStateOf(false) } + var lockMethodRoot by remember { mutableStateOf(false) } + var showRootModePrompt by remember { mutableStateOf(false) } + var isVerifyingRoot by remember { mutableStateOf(false) } + var timeoutValue by remember { mutableFloatStateOf(300f) } var showIcon by remember { mutableStateOf(false) } var widgetRippleEnabled by remember { mutableStateOf(true) } @@ -310,6 +323,35 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } + fun applyLockMethod(useRoot: Boolean, fromPrompt: Boolean) { + val prefs = context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) + if (!useRoot) { + lockMethodRoot = false + prefs.edit { + putString(lockMethodKey, RootLock.LOCK_METHOD_ACCESSIBILITY) + if (fromPrompt) putBoolean(rootModePromptShownKey, true) + } + if (fromPrompt) showRootModePrompt = false + return + } + isVerifyingRoot = true + RootLock.verifyRootAccess { granted -> + isVerifyingRoot = false + lockMethodRoot = granted + prefs.edit { + putString( + lockMethodKey, + if (granted) RootLock.LOCK_METHOD_ROOT else RootLock.LOCK_METHOD_ACCESSIBILITY + ) + if (fromPrompt) putBoolean(rootModePromptShownKey, true) + } + if (fromPrompt) showRootModePrompt = false + if (!granted) { + Toast.makeText(context, rootAccessDeniedMsg, Toast.LENGTH_LONG).show() + } + } + } + fun setEditableEdgeMode(side: EdgeZoneSide, mode: TapZoneMode) { when (side) { EdgeZoneSide.LEFT -> leftEdgeMode = mode @@ -500,6 +542,11 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { TapLockAppRules.loadLaunchableApps(context) } isLoadingApps = false + + lockMethodRoot = prefs.getString(lockMethodKey, null) == RootLock.LOCK_METHOD_ROOT + val rooted = withContext(Dispatchers.IO) { RootLock.isDeviceRooted() } + isDeviceRooted = rooted + showRootModePrompt = rooted && !prefs.getBoolean(rootModePromptShownKey, false) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> @@ -628,6 +675,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { isBatteryOptimized = !pm.isIgnoringBatteryOptimizations(context.packageName) val prefs = context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) lockCount = prefs.getInt(lockCountKey, 0) + lockMethodRoot = prefs.getString(lockMethodKey, null) == RootLock.LOCK_METHOD_ROOT showIcon = prefs.getBoolean(showWidgetIconKey, false) widgetRippleEnabled = prefs.getBoolean(widgetRippleEnabledKey, true) widgetStyle = TapLockWidgetStyle.fromStored(prefs.getString(widgetStyleKey, null)) @@ -891,6 +939,56 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } + if (isDeviceRooted) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.lock_method_label), + style = MaterialTheme.typography.titleMedium + ) + Text( + stringResource(R.string.lock_method_description), + style = MaterialTheme.typography.bodyMedium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = !lockMethodRoot, + onClick = { applyLockMethod(useRoot = false, fromPrompt = false) }, + enabled = !isVerifyingRoot, + label = { Text(stringResource(R.string.lock_method_accessibility)) }, + modifier = Modifier.testTag("chip_lock_method_accessibility") + ) + FilterChip( + selected = lockMethodRoot, + onClick = { applyLockMethod(useRoot = true, fromPrompt = false) }, + enabled = !isVerifyingRoot, + label = { Text(stringResource(R.string.lock_method_root)) }, + modifier = Modifier.testTag("chip_lock_method_root") + ) + } + if (isVerifyingRoot) { + Text( + stringResource(R.string.root_mode_checking), + style = MaterialTheme.typography.bodySmall + ) + } + Text( + stringResource(R.string.lock_method_root_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + val accessibilityHint = stringResource(R.string.accessibility_required_hint) val appWidgetManager = remember { AppWidgetManager.getInstance(context) } val widgetPinSupported = remember { appWidgetManager.isRequestPinAppWidgetSupported } @@ -1858,6 +1956,35 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { ) } + if (showRootModePrompt && !showOnboarding) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.root_mode_prompt_title)) }, + text = { + Text( + if (isVerifyingRoot) stringResource(R.string.root_mode_checking) + else stringResource(R.string.root_mode_prompt_body) + ) + }, + confirmButton = { + TextButton( + onClick = { applyLockMethod(useRoot = true, fromPrompt = true) }, + enabled = !isVerifyingRoot + ) { + Text(stringResource(R.string.root_mode_use_root)) + } + }, + dismissButton = { + TextButton( + onClick = { applyLockMethod(useRoot = false, fromPrompt = true) }, + enabled = !isVerifyingRoot + ) { + Text(stringResource(R.string.root_mode_use_accessibility)) + } + } + ) + } + if (showOnboarding) { val onboardingTitle = when (onboardingStep) { 0 -> stringResource(R.string.onboarding_welcome_title) diff --git a/app/src/main/java/com/ah/taplock/RootLock.kt b/app/src/main/java/com/ah/taplock/RootLock.kt new file mode 100644 index 0000000..00fe9d4 --- /dev/null +++ b/app/src/main/java/com/ah/taplock/RootLock.kt @@ -0,0 +1,126 @@ +package com.ah.taplock + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.content.edit +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +/** + * Root-based lock implementation. Instead of GLOBAL_ACTION_LOCK_SCREEN (which briefly shows the + * lock screen before the display turns off, causing a visible flicker), it injects KEYCODE_POWER + * via a root shell — the exact effect of pressing the power button: the screen turns off + * immediately with no flicker. + */ +object RootLock { + + private const val TAG = "TapLock" + const val LOCK_METHOD_ACCESSIBILITY = "ACCESSIBILITY" + const val LOCK_METHOD_ROOT = "ROOT" + private const val KEYCODE_POWER_COMMAND = "input keyevent 26" + + // Reused across locks so each lock only pays for the `input` call, not a new su session. + private var shell: Process? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + /** Cheap heuristic check for a rooted device: looks for a `su` binary on disk and in PATH. */ + fun isDeviceRooted(): Boolean { + val commonPaths = listOf( + "/system/bin/su", + "/system/xbin/su", + "/system/sbin/su", + "/sbin/su", + "/su/bin/su", + "/vendor/bin/su", + "/odm/bin/su", + "/product/bin/su", + "/data/local/xbin/su", + "/data/local/bin/su" + ) + if (commonPaths.any { runCatching { File(it).exists() }.getOrDefault(false) }) return true + val pathDirs = System.getenv("PATH")?.split(':').orEmpty() + return pathDirs.any { dir -> + dir.isNotEmpty() && runCatching { File(dir, "su").exists() }.getOrDefault(false) + } + } + + /** True when the user selected root as the lock method. */ + fun isRootLockEnabled(context: Context): Boolean { + val prefs = context.getSharedPreferences( + context.getString(R.string.shared_pref_name), + Context.MODE_PRIVATE + ) + return prefs.getString( + context.getString(R.string.lock_method), + null + ) == LOCK_METHOD_ROOT + } + + /** + * Runs `su -c id` to trigger the superuser grant prompt and verify access. [onResult] is + * invoked on the main thread. + */ + fun verifyRootAccess(onResult: (Boolean) -> Unit) { + thread(name = "TapLockRootCheck") { + val granted = runCatching { + val process = ProcessBuilder("su", "-c", "id").start() + val output = process.inputStream.bufferedReader().readText() + // Generous timeout: the superuser manager may wait for the user to respond. + val finished = process.waitFor(60, TimeUnit.SECONDS) + if (!finished) process.destroyForcibly() + finished && process.exitValue() == 0 && output.contains("uid=0") + }.getOrDefault(false) + mainHandler.post { onResult(granted) } + } + } + + /** + * Locks the screen by injecting KEYCODE_POWER through the root shell. Increments the lock + * counter on success. [onResult] is invoked on the main thread; callers can fall back to the + * accessibility lock when it reports false. + */ + fun performRootLock(context: Context, onResult: (Boolean) -> Unit = {}) { + val appContext = context.applicationContext + thread(name = "TapLockRootLock") { + val success = runShellCommand(KEYCODE_POWER_COMMAND) + if (success) { + val prefs = appContext.getSharedPreferences( + appContext.getString(R.string.shared_pref_name), + Context.MODE_PRIVATE + ) + val count = prefs.getInt(appContext.getString(R.string.lock_count), 0) + prefs.edit { putInt(appContext.getString(R.string.lock_count), count + 1) } + } else { + Log.w(TAG, "Root lock failed: could not write to su shell") + } + mainHandler.post { onResult(success) } + } + } + + @Synchronized + private fun runShellCommand(command: String): Boolean { + repeat(2) { + val process = shell?.takeIf { it.isAlive } + ?: runCatching { ProcessBuilder("su").start() } + .getOrNull() + ?.also { shell = it } + ?: return false + try { + process.outputStream.write("$command\n".toByteArray()) + process.outputStream.flush() + return true + } catch (e: IOException) { + // Shell died (e.g. permission revoked); drop it and retry once with a fresh one. + Log.d(TAG, "Root shell write failed, restarting shell", e) + runCatching { process.destroy() } + shell = null + } + } + return false + } +} diff --git a/app/src/main/java/com/ah/taplock/TapLockAccessibilityService.kt b/app/src/main/java/com/ah/taplock/TapLockAccessibilityService.kt index bda76d3..7e565ef 100644 --- a/app/src/main/java/com/ah/taplock/TapLockAccessibilityService.kt +++ b/app/src/main/java/com/ah/taplock/TapLockAccessibilityService.kt @@ -108,6 +108,16 @@ class TapLockAccessibilityService : AccessibilityService() { } fun lockScreen() { + if (RootLock.isRootLockEnabled(this)) { + RootLock.performRootLock(this) { success -> + if (!success) lockScreenWithAccessibility() + } + } else { + lockScreenWithAccessibility() + } + } + + private fun lockScreenWithAccessibility() { val prefs = getPrefs() val count = prefs.getInt(getString(R.string.lock_count), 0) prefs.edit { putInt(getString(R.string.lock_count), count + 1) } diff --git a/app/src/main/java/com/ah/taplock/TapLockFeedback.kt b/app/src/main/java/com/ah/taplock/TapLockFeedback.kt index a2b744e..085bd89 100644 --- a/app/src/main/java/com/ah/taplock/TapLockFeedback.kt +++ b/app/src/main/java/com/ah/taplock/TapLockFeedback.kt @@ -13,6 +13,10 @@ object TapLockFeedback { toast(context, R.string.accessibility_permission_required, Toast.LENGTH_LONG) } + fun showRootLockFailed(context: Context) { + toast(context, R.string.root_lock_failed, Toast.LENGTH_LONG) + } + fun showWidgetPinUnsupported(context: Context) { toast(context, R.string.widget_pin_not_supported_toast, Toast.LENGTH_LONG) } diff --git a/app/src/main/java/com/ah/taplock/TapLockTileService.kt b/app/src/main/java/com/ah/taplock/TapLockTileService.kt index 3180cef..3e31b48 100644 --- a/app/src/main/java/com/ah/taplock/TapLockTileService.kt +++ b/app/src/main/java/com/ah/taplock/TapLockTileService.kt @@ -55,6 +55,18 @@ class TapLockTileService : TileService() { } else { service.lockScreen() } + } else if (RootLock.isRootLockEnabled(this)) { + val lockViaRoot = Runnable { + RootLock.performRootLock(this) { success -> + if (!success) TapLockFeedback.showRootLockFailed(this) + } + } + val totalDelay = (if (vibrateOnLock) 100L else 0L) + lockDelay + if (totalDelay > 0) { + Handler(Looper.getMainLooper()).postDelayed(lockViaRoot, totalDelay) + } else { + lockViaRoot.run() + } } else { // Fallback checking if enabled if (isAccessibilityEnabled(this)) { @@ -87,7 +99,7 @@ class TapLockTileService : TileService() { private fun updateTileState() { qsTile?.let { tile -> - val isEnabled = isAccessibilityEnabled(this) + val isEnabled = isAccessibilityEnabled(this) || RootLock.isRootLockEnabled(this) // STATE_INACTIVE is the correct state for a button that performs an action but doesn't have an on/off state. // It will appear white/grey (depending on theme) but not "highlighted/accented" like an active toggle. diff --git a/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt b/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt index b61dfa0..6d346f3 100644 --- a/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt +++ b/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt @@ -127,6 +127,11 @@ class TapLockWidgetProvider : AppWidgetProvider() { if (service != null) { Log.d("TapLock", "Widget: Using direct instance - fast path") service.lockScreen() + } else if (RootLock.isRootLockEnabled(context)) { + Log.d("TapLock", "Widget: No service - locking via root") + RootLock.performRootLock(context) { success -> + if (!success) TapLockFeedback.showRootLockFailed(context) + } } else if (isAccessibilityEnabled(context)) { Log.d("TapLock", "Widget: Instance null - using slow startService path") Toast.makeText(context, R.string.locking_screen, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a77fdb9..f233337 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -83,6 +83,25 @@ funcionar mientras la Protección avanzada esté activada. Para usar TapLock, desactiva la Protección avanzada en Ajustes > Seguridad y privacidad. Abrir ajustes de seguridad + Método de bloqueo + Este dispositivo parece estar rooteado. Elige cómo + TapLock apaga la pantalla. + Accesibilidad + Root (botón de encendido) + El modo root simula una pulsación real del botón de + encendido, por lo que la pantalla se apaga al instante sin el breve parpadeo de la pantalla + de bloqueo. Las zonas de toque y el botón flotante siguen necesitando el servicio de + accesibilidad; el widget y el mosaico de ajustes rápidos funcionan solo con root. + Root detectado + Tu dispositivo parece estar rooteado. TapLock puede + bloquear la pantalla mediante root simulando el botón de encendido: la pantalla se apaga al + instante, sin el parpadeo del bloqueo estándar por accesibilidad. Puedes cambiarlo más + tarde en los ajustes. ¿Qué método quieres usar? + Usar root + Usar accesibilidad + Comprobando acceso root… + Acceso root denegado: se usará el bloqueo por accesibilidad + Error del bloqueo por root Bienvenido a TapLock Bloquea la pantalla al instante con un doble toque. Vamos a configurarlo en unos pocos pasos. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17d9fbc..7a847ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,6 +112,28 @@ privacy. Open Security settings + lock_method + root_mode_prompt_shown + Lock Method + This device appears to be rooted. Choose how TapLock + turns off the screen. + Accessibility + Root (power button) + Root mode simulates a real power button press, so the + screen turns off instantly without the brief lock-screen flicker. Tap zones and the + floating button still need the accessibility service; the widget and Quick Settings tile + work with root alone. + Root Detected + Your device appears to be rooted. TapLock can lock the + screen via root by simulating the power button: the screen turns off instantly, without + the flicker of the standard accessibility lock. You can change this later in settings. + Which method do you want to use? + Use Root + Use Accessibility + Checking root access… + Root access denied — using accessibility lock + Root lock failed + has_completed_onboarding Welcome to TapLock Lock your screen instantly with a double-tap. Let\'s get From aaa0b623dd6a893b0f52f7fb03032d9d518bf299 Mon Sep 17 00:00:00 2001 From: Nello Carotenuto Date: Wed, 2 Sep 2026 00:08:03 +0200 Subject: [PATCH 3/6] Enhance code documentation and improve clarity across multiple files - Added detailed KDoc comments to various classes and functions to improve code readability and maintainability. - Updated `TapLockAppRules.kt` with explanations for app exclusion rules and package management. - Enhanced `TapLockBatteryOptimization.kt` with comments on intent builders for battery optimization. - Improved documentation in `TapLockEdgeZones.kt`, `TapLockFeedback.kt`, `TapLockFloatingButtonConfig.kt`, `TapLockLockZone.kt`, `TapLockStatusBarZone.kt`, `TapLockTileService.kt`, `TapLockWidgetProvider.kt`, `TapLockWidgetStyle.kt`, `TapZoneMode.kt`, `Utils.kt`, `VibrationHelper.kt`, and `WidgetComponents.kt` to clarify functionality and usage. - Added comments to `Theme.kt` to describe the app-wide theme configuration. --- .../java/com/ah/taplock/AppExclusionDialog.kt | 6 + .../java/com/ah/taplock/DoubleTapDetector.kt | 9 ++ .../java/com/ah/taplock/EdgeZoneComponents.kt | 9 ++ .../ah/taplock/LockScreenZoneComponents.kt | 8 + .../main/java/com/ah/taplock/MainActivity.kt | 52 ++++++ .../java/com/ah/taplock/SettingsComponents.kt | 17 ++ .../ah/taplock/TapLockAccessibilityService.kt | 153 ++++++++++++++++++ .../java/com/ah/taplock/TapLockAppRules.kt | 25 +++ .../ah/taplock/TapLockBatteryOptimization.kt | 4 + .../java/com/ah/taplock/TapLockEdgeZones.kt | 17 ++ .../java/com/ah/taplock/TapLockFeedback.kt | 8 + .../ah/taplock/TapLockFloatingButtonConfig.kt | 3 + .../java/com/ah/taplock/TapLockLockZone.kt | 9 ++ .../com/ah/taplock/TapLockStatusBarZone.kt | 10 ++ .../java/com/ah/taplock/TapLockTileService.kt | 15 ++ .../com/ah/taplock/TapLockWidgetProvider.kt | 26 +++ .../java/com/ah/taplock/TapLockWidgetStyle.kt | 2 + .../main/java/com/ah/taplock/TapZoneMode.kt | 2 + app/src/main/java/com/ah/taplock/Utils.kt | 5 + .../java/com/ah/taplock/VibrationHelper.kt | 8 + .../java/com/ah/taplock/WidgetComponents.kt | 8 + .../java/com/ah/taplock/ui/theme/Theme.kt | 4 + 22 files changed, 400 insertions(+) diff --git a/app/src/main/java/com/ah/taplock/AppExclusionDialog.kt b/app/src/main/java/com/ah/taplock/AppExclusionDialog.kt index 15d1e0e..1dca94b 100644 --- a/app/src/main/java/com/ah/taplock/AppExclusionDialog.kt +++ b/app/src/main/java/com/ah/taplock/AppExclusionDialog.kt @@ -23,6 +23,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +/** + * Dialog listing launchable [apps] with checkboxes to build [excludedPackages]. Filtering by + * [searchQuery] matches both label and package name; all state is hoisted to the caller so + * toggles persist immediately. + */ @Composable fun AppExclusionDialog( apps: List, @@ -124,6 +129,7 @@ fun AppExclusionDialog( ) } +/** Single app row: checkbox plus label and package name, toggled by tapping anywhere. */ @Composable private fun AppExclusionRow( app: TapLockAppInfo, diff --git a/app/src/main/java/com/ah/taplock/DoubleTapDetector.kt b/app/src/main/java/com/ah/taplock/DoubleTapDetector.kt index 93c7034..0df8f7b 100644 --- a/app/src/main/java/com/ah/taplock/DoubleTapDetector.kt +++ b/app/src/main/java/com/ah/taplock/DoubleTapDetector.kt @@ -1,10 +1,18 @@ package com.ah.taplock +/** + * Time-window double-tap detector. [currentTimeMs] is injectable so tests can drive the clock + * deterministically instead of relying on real time. + */ class DoubleTapDetector( private val currentTimeMs: () -> Long = System::currentTimeMillis ) { private var lastTapTime = 0L + /** + * Records a tap at [tapTimeMs] and returns true when it lands within [timeoutMs] of the + * previous one. A detected double tap consumes both taps, so a third tap starts a new window. + */ fun onTap(timeoutMs: Int, tapTimeMs: Long = currentTimeMs()): Boolean { if (lastTapTime != 0L && tapTimeMs - lastTapTime < timeoutMs) { lastTapTime = 0L @@ -14,6 +22,7 @@ class DoubleTapDetector( return false } + /** Forgets the pending tap, e.g. when the tap target moves or is resized mid-sequence. */ fun reset() { lastTapTime = 0L } diff --git a/app/src/main/java/com/ah/taplock/EdgeZoneComponents.kt b/app/src/main/java/com/ah/taplock/EdgeZoneComponents.kt index 22a83d4..7e9ae7d 100644 --- a/app/src/main/java/com/ah/taplock/EdgeZoneComponents.kt +++ b/app/src/main/java/com/ah/taplock/EdgeZoneComponents.kt @@ -20,6 +20,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +/** + * Miniature phone mockup shown in settings that mirrors the configured edge and corner zones. + * Dimensions are scaled to the fixed preview frame, not real screen pixels, so widths map the + * supported dp range onto a visually useful size. + */ @Composable fun EdgeZonePreview( leftEnabled: Boolean, @@ -162,6 +167,10 @@ fun EdgeZonePreview( } } +/** + * Full-screen translucent overlay drawn while the user drags an edge/corner slider, showing the + * real zone positions at true dp sizes on the actual screen. + */ @Composable fun EdgeZoneLiveOverlay( leftEnabled: Boolean, diff --git a/app/src/main/java/com/ah/taplock/LockScreenZoneComponents.kt b/app/src/main/java/com/ah/taplock/LockScreenZoneComponents.kt index edce6a9..754b52f 100644 --- a/app/src/main/java/com/ah/taplock/LockScreenZoneComponents.kt +++ b/app/src/main/java/com/ah/taplock/LockScreenZoneComponents.kt @@ -18,6 +18,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +/** + * Miniature phone mockup shown in settings that mirrors the configured lock-screen tap zone, + * scaled to a fixed preview frame rather than real screen pixels. + */ @Composable fun LockScreenZonePreview( lockZonePercent: Int, @@ -75,6 +79,10 @@ fun LockScreenZonePreview( } } +/** + * Full-screen translucent overlay drawn while the user drags the lock-zone sliders, showing the + * real zone extent on the actual screen. + */ @Composable fun LockScreenZoneLiveOverlay( lockZonePercent: Int, diff --git a/app/src/main/java/com/ah/taplock/MainActivity.kt b/app/src/main/java/com/ah/taplock/MainActivity.kt index 5e2fd2b..bf5a177 100644 --- a/app/src/main/java/com/ah/taplock/MainActivity.kt +++ b/app/src/main/java/com/ah/taplock/MainActivity.kt @@ -103,11 +103,17 @@ import kotlin.time.Duration.Companion.milliseconds private const val TAG = "TapLock" +/** + * Single-activity entry point. Hosts the Compose settings UI, or acts as an invisible trampoline + * that locks the screen and finishes immediately when launched with the LOCK_NOW action. + */ class MainActivity : ComponentActivity() { + /** Handles the LOCK_NOW trampoline before any UI; otherwise renders [TapLockScreen]. */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (intent?.action == "com.ah.taplock.LOCK_NOW") { + // Prefer the live accessibility service; fall back to root when it isn't bound. val service = TapLockAccessibilityService.instance if (service != null) { service.lockScreen() @@ -125,6 +131,11 @@ class MainActivity : ComponentActivity() { } } +/** + * Full settings screen: permission status, quick-access setup (widget, tile, floating button), + * tap-zone configuration, behavior tweaks, and app exclusions. [accessibilityEnabledOverride] + * forces the accessibility state in tests instead of querying the system. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { @@ -230,6 +241,8 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { mutableFloatStateOf(TapLockEdgeZones.DEFAULT_CORNER_SIZE_DP.toFloat()) } var floatingButtonEnabled by remember { mutableStateOf(false) } + // State objects are kept next to their delegates so slider callbacks can read the latest + // value via floatValue instead of a stale lambda capture. val floatingButtonSizeDpState = remember { mutableFloatStateOf(TapLockFloatingButtonConfig.DEFAULT_SIZE_DP.toFloat()) } @@ -274,6 +287,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { editableTopRightCornerZoneEnabled || editableBottomLeftCornerZoneEnabled || editableBottomRightCornerZoneEnabled + // Live overlays render only while a relevant slider is actively dragged. val showEdgeZoneLiveOverlay = ( anyEdgeZoneEnabled || anyCornerZoneEnabled ) && ( @@ -285,6 +299,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val showLockZoneLiveOverlay = lockScreenMode != TapZoneMode.OFF && (isLockZoneSliderDragged || isLockZoneTopOffsetSliderDragged || showLockZonePreviewOverlay) + // The preview button flashes the real overlay briefly, then hides it again. LaunchedEffect(showLockZonePreviewOverlay) { if (showLockZonePreviewOverlay) { delay(1500.milliseconds) @@ -292,16 +307,22 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } + /** Persists a tap-zone mode selection under [baseKey]. */ fun saveSelectedZoneMode(baseKey: String, value: TapZoneMode) { context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) .edit { putString(baseKey, value.name) } } + /** Persists an integer zone setting under [baseKey]. */ fun saveSelectedZoneInt(baseKey: String, value: Int) { context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) .edit { putInt(baseKey, value) } } + /** + * Clamps and applies lock-zone values to state. The top offset ceiling depends on + * [zonePercent], so both values are re-clamped together to keep the zone on screen. + */ fun syncLockZoneState( zonePercent: Int = lockZonePercent.toInt(), topOffsetPercent: Int = lockZoneTopOffsetPercent.toInt() @@ -315,6 +336,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { lockZoneTopOffsetPercent = clampedTopOffsetPercent.toFloat() } + /** Re-clamps the current lock-zone state and writes both values to preferences. */ fun persistLockZoneSettings() { syncLockZoneState() context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE).edit { @@ -323,6 +345,11 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } + /** + * Switches the lock method. Choosing root first verifies su access asynchronously and falls + * back to accessibility (with a toast) when denied. [fromPrompt] marks the one-time root + * prompt as answered so it never reappears. + */ fun applyLockMethod(useRoot: Boolean, fromPrompt: Boolean) { val prefs = context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) if (!useRoot) { @@ -352,6 +379,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } + /** Updates and persists the tap mode for the given edge [side]. */ fun setEditableEdgeMode(side: EdgeZoneSide, mode: TapZoneMode) { when (side) { EdgeZoneSide.LEFT -> leftEdgeMode = mode @@ -360,6 +388,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { saveSelectedZoneMode(if (side == EdgeZoneSide.LEFT) leftEdgeModeKey else rightEdgeModeKey, mode) } + /** Updates and persists the tap mode for the given corner [position]. */ fun setEditableCornerMode(position: CornerZonePosition, mode: TapZoneMode) { val key = when (position) { CornerZonePosition.TOP_LEFT -> { @@ -382,37 +411,48 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { saveSelectedZoneMode(key, mode) } + /** Updates the edge-zone width state during slider drag; persisted on release. */ fun setEditableEdgeWidth(value: Float) { edgeZoneWidthDp = value } + /** Updates the edge-zone top offset state during slider drag; persisted on release. */ fun setEditableTopOffset(value: Float) { edgeZoneTopOffsetPercent = value } + /** Updates the edge-zone bottom offset state during slider drag; persisted on release. */ fun setEditableBottomOffset(value: Float) { edgeZoneBottomOffsetPercent = value } + /** Updates the corner-zone size state during slider drag; persisted on release. */ fun setEditableCornerSize(value: Float) { cornerZoneSizeDp = value } + /** Re-reads how many home-screen widgets are currently placed. */ fun refreshWidgetCount() { widgetCount = TapLockWidgetProvider.getWidgetCount(context) } + /** Redraws all placed widgets and refreshes the count shown in the UI. */ fun refreshWidgets() { TapLockWidgetProvider.refreshAll(context) refreshWidgetCount() } + /** Asks the running accessibility service to rebuild the floating button, if shown. */ fun refreshFloatingLockButtonIfRunning() { if (floatingButtonEnabled) { TapLockAccessibilityService.instance?.refreshFloatingLockButton() } } + /** + * Live-previews floating button size/opacity on the service overlay while a slider moves, + * without persisting anything. + */ fun previewFloatingLockButtonIfRunning( sizeDp: Float = floatingButtonSizeDp, opacityPercent: Float = floatingButtonOpacityPercent @@ -427,6 +467,10 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } + /** + * Shows the system prompt to add the Quick Settings tile (Android 13+). Persists the added + * flag when the tile is added or already present; older versions get an unsupported toast. + */ fun requestQuickSettingsTilePrompt() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { TapLockFeedback.showQuickSettingsAddUnsupported(context) @@ -497,6 +541,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { edgeZoneWidthDpKey, TapLockEdgeZones.DEFAULT_WIDTH_DP ).toFloat() + // Older versions stored one "coverage" value; derive top/bottom offset defaults from it. val legacyEdgeCoveragePercent = prefs.getInt( edgeZoneCoveragePercentKey, TapLockEdgeZones.DEFAULT_COVERAGE_PERCENT @@ -544,6 +589,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { isLoadingApps = false lockMethodRoot = prefs.getString(lockMethodKey, null) == RootLock.LOCK_METHOD_ROOT + // su detection touches the filesystem, so keep it off the main thread. val rooted = withContext(Dispatchers.IO) { RootLock.isDeviceRooted() } isDeviceRooted = rooted showRootModePrompt = rooted && !prefs.getBoolean(rootModePromptShownKey, false) @@ -556,6 +602,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { try { context.contentResolver.openInputStream(uri)?.use { inputStream -> val bitmap = BitmapFactory.decodeStream(inputStream) + // Cap at 512px so the stored icon stays small enough for RemoteViews. val scaledBitmap = if (bitmap.width > 512 || bitmap.height > 512) { bitmap.scale(512, 512) } else { @@ -581,6 +628,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } + // Adaptive icons expose no bitmap directly, so render the launcher drawable onto a canvas. val defaultAppIconBitmap = remember { val drawable = context.packageManager.getApplicationIcon(context.packageName) val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 128 @@ -644,6 +692,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } else null + // Tiramisu+ has a dedicated listener; older versions must watch the Settings.Secure URI. val accessibilityObserver = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val listener = @@ -667,6 +716,8 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } refreshAccessibilityState() + // Widgets, the tile, and system Settings can change prefs while the app is backgrounded, + // so re-read everything on every resume. val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { isAccessibilityEnabled = accessibilityEnabledOverride ?: isAccessibilityEnabled(context) @@ -1956,6 +2007,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { ) } + // Defer the root prompt until onboarding finishes so the dialogs don't stack. if (showRootModePrompt && !showOnboarding) { AlertDialog( onDismissRequest = {}, diff --git a/app/src/main/java/com/ah/taplock/SettingsComponents.kt b/app/src/main/java/com/ah/taplock/SettingsComponents.kt index c476fca..db9825d 100644 --- a/app/src/main/java/com/ah/taplock/SettingsComponents.kt +++ b/app/src/main/java/com/ah/taplock/SettingsComponents.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay +/** Standard settings card with a [title], secondary [description], and arbitrary [content]. */ @Composable fun SettingsSectionCard( title: String, @@ -72,6 +73,10 @@ fun SettingsSectionCard( } } +/** + * Collapsible "about" card with app description, instructions, and [disclaimerString]. + * [expanded] state is hoisted so the screen can persist it across recompositions. + */ @Composable fun InfoSection( expanded: Boolean, @@ -132,6 +137,10 @@ fun InfoSection( } } +/** + * Small "Advanced" chevron row that expands to reveal [content]. [testTag] labels the toggle row + * for UI tests and screenshot automation. + */ @Composable fun AdvancedDisclosure( expanded: Boolean, @@ -182,6 +191,10 @@ fun AdvancedDisclosure( } } +/** + * Practice box that flashes green when a double tap lands within [timeoutMs], letting the user + * calibrate the timeout without locking the device. + */ @Composable fun DoubleTapTestArea(timeoutMs: Int) { val detector = remember { DoubleTapDetector() } @@ -240,6 +253,10 @@ fun DoubleTapTestArea(timeoutMs: Int) { } } +/** + * Segmented off/double-tap/single-tap selector for one tap zone. Each button gets a test tag + * derived from [testTag] and the mode name. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TapModeSelector( diff --git a/app/src/main/java/com/ah/taplock/TapLockAccessibilityService.kt b/app/src/main/java/com/ah/taplock/TapLockAccessibilityService.kt index 7e565ef..5409df1 100644 --- a/app/src/main/java/com/ah/taplock/TapLockAccessibilityService.kt +++ b/app/src/main/java/com/ah/taplock/TapLockAccessibilityService.kt @@ -26,11 +26,18 @@ import androidx.annotation.StringRes import androidx.core.content.edit import kotlin.math.abs +/** + * Core accessibility service: hosts the invisible tap-zone overlays (status bar, lock screen, + * edges, corners) and the draggable floating lock button, and turns detected taps into screen + * locks. + */ class TapLockAccessibilityService : AccessibilityService() { + /** Static access to the running service for the widget, tile, and settings UI. */ companion object { private const val TAG = "TapLock" private const val SYSTEM_UI_PACKAGE = "com.android.systemui" + /** Live service instance, or null while the service is not connected. */ @Suppress("StaticFieldLeak") // Intentional: cleared in onUnbind/onDestroy var instance: TapLockAccessibilityService? = null private set @@ -57,6 +64,7 @@ class TapLockAccessibilityService : AccessibilityService() { private var isOnLockScreen = false private var currentForegroundPackage: String? = null + /** Pixel geometry of the status bar / lock screen overlay window. */ private data class StatusBarOverlayFrame( val widthPx: Int, val heightPx: Int, @@ -64,6 +72,10 @@ class TapLockAccessibilityService : AccessibilityService() { val yPx: Int ) + /** + * Publishes the static [instance], opts into window-tracking events (needed to observe the + * lock screen and the foreground app), and creates the configured overlays. + */ override fun onServiceConnected() { super.onServiceConnected() instance = this @@ -80,6 +92,7 @@ class TapLockAccessibilityService : AccessibilityService() { registerPrefListener() } + /** Clears the static [instance] and tears down all overlays when the service is disabled. */ override fun onUnbind(intent: Intent?): Boolean { instance = null removeStatusBarOverlay() @@ -90,6 +103,7 @@ class TapLockAccessibilityService : AccessibilityService() { return super.onUnbind(intent) } + /** Same teardown as [onUnbind]; both run so the static [instance] never outlives the service. */ override fun onDestroy() { instance = null removeStatusBarOverlay() @@ -100,6 +114,7 @@ class TapLockAccessibilityService : AccessibilityService() { super.onDestroy() } + /** Handles lock requests delivered as ACTION_SCREEN_OFF intents (e.g. from the widget or tile). */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == Intent.ACTION_SCREEN_OFF) { lockScreen() @@ -107,6 +122,10 @@ class TapLockAccessibilityService : AccessibilityService() { return super.onStartCommand(intent, flags, startId) } + /** + * Locks the screen using the configured method: root lock when enabled (falling back to the + * accessibility lock if the root shell fails), otherwise GLOBAL_ACTION_LOCK_SCREEN. + */ fun lockScreen() { if (RootLock.isRootLockEnabled(this)) { RootLock.performRootLock(this) { success -> @@ -117,6 +136,7 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Increments the lock counter and locks via GLOBAL_ACTION_LOCK_SCREEN. */ private fun lockScreenWithAccessibility() { val prefs = getPrefs() val count = prefs.getInt(getString(R.string.lock_count), 0) @@ -124,11 +144,16 @@ class TapLockAccessibilityService : AccessibilityService() { performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN) } + /** Re-resolves the foreground app and reports whether it is on the exclusion list. */ fun isForegroundAppExcludedNow(): Boolean { refreshForegroundPackage() return isCurrentAppExcluded() } + /** + * Tracks foreground app changes to keep overlay touchability in sync, and reacts to window + * changes (lock screen shown/hidden, shade expanded) by re-laying-out all overlays. + */ override fun onAccessibilityEvent(event: AccessibilityEvent?) { event ?: return @@ -147,12 +172,15 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** No-op: the service keeps no interruptible feedback state. */ override fun onInterrupt() {} + /** Returns the app's shared preferences file. */ private fun getPrefs(): SharedPreferences { return getSharedPreferences(getString(R.string.shared_pref_name), MODE_PRIVATE) } + /** True when [key] is an edge/corner zone preference that requires a zone re-layout. */ private fun isZonePreferenceKey(key: String?): Boolean { if (key == null) return false return key == getString(R.string.left_edge_mode) || @@ -167,6 +195,10 @@ class TapLockAccessibilityService : AccessibilityService() { key == getString(R.string.edge_zone_bottom_offset_percent) } + /** + * Listens for preference changes and applies them live, so overlays react immediately while + * the settings UI is open. + */ private fun registerPrefListener() { val prefs = getPrefs() prefListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> @@ -199,9 +231,11 @@ class TapLockAccessibilityService : AccessibilityService() { prefs.registerOnSharedPreferenceChangeListener(prefListener) } + /** Reads the [TapZoneMode] stored under the string resource [key]. */ private fun getZoneMode(prefs: SharedPreferences, @StringRes key: Int): TapZoneMode = TapZoneMode.fromStored(prefs.getString(getString(key), null)) + /** Stops listening for preference changes. */ private fun unregisterPrefListener() { prefListener?.let { getPrefs().unregisterOnSharedPreferenceChangeListener(it) @@ -209,6 +243,7 @@ class TapLockAccessibilityService : AccessibilityService() { prefListener = null } + /** Re-evaluates the status bar overlay and all edge/corner zone overlays. */ private fun updateOverlay() { updateStatusBarOverlay() updateInteractiveZoneOverlays() @@ -230,6 +265,7 @@ class TapLockAccessibilityService : AccessibilityService() { updateFloatingLockButtonLayout() } + /** Shows, updates, or removes the floating button to match the enabled preference. */ private fun updateFloatingLockButton() { val enabled = getPrefs().getBoolean(getString(R.string.floating_button_enabled), false) if (!enabled) { @@ -246,6 +282,10 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** + * Creates the draggable floating lock button overlay. A touch within slop is a tap that locks + * the screen; a drag moves the button and snaps it to the nearest side on release. + */ @Suppress("ClickableViewAccessibility") private fun addFloatingLockButton() { if (floatingLockButton != null) return @@ -349,6 +389,7 @@ class TapLockAccessibilityService : AccessibilityService() { MotionEvent.ACTION_UP -> { if (isDragging) { val currentBounds = wm.currentWindowMetrics.bounds + // Snap to the closer horizontal screen edge. params.x = if (params.x + (params.width / 2) < currentBounds.width() / 2) { 0 } else { @@ -371,6 +412,7 @@ class TapLockAccessibilityService : AccessibilityService() { lockScreen() } } + // Accessibility contract: performClick() after handling ACTION_UP manually. view.performClick() true } @@ -388,6 +430,10 @@ class TapLockAccessibilityService : AccessibilityService() { floatingLockButton = button } + /** + * Applies current size/opacity (preview values take precedence), clamps the position to the + * screen, and refreshes the icon from the custom icon file when present. + */ private fun updateFloatingLockButtonLayout() { val button = floatingLockButton ?: return val prefs = getPrefs() @@ -429,6 +475,7 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Removes the floating button overlay if present. */ private fun removeFloatingLockButton() { floatingLockButton?.let { (getSystemService(WINDOW_SERVICE) as WindowManager).removeView(it) @@ -436,6 +483,7 @@ class TapLockAccessibilityService : AccessibilityService() { floatingLockButton = null } + /** Stores the floating button position so it survives service restarts. */ private fun persistFloatingButtonPosition(x: Int, y: Int) { getPrefs().edit { putInt(getString(R.string.floating_button_position_x), x) @@ -443,9 +491,14 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Converts [valueDp] to pixels using the current display density. */ private fun dpToPx(valueDp: Int): Int = (valueDp * resources.displayMetrics.density).toInt() + /** + * Adds or removes the status bar overlay depending on whether the status bar or lock screen + * tap zone is enabled, then re-applies its frame. + */ private fun updateStatusBarOverlay() { val prefs = getPrefs() val statusBarMode = getZoneMode(prefs, R.string.status_bar_mode) @@ -465,6 +518,10 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** + * Creates the overlay covering the status bar (or the lock zone while locked). Vertical + * swipes are forwarded as shade/keyguard gestures; movement-free taps feed the detector. + */ private fun addStatusBarOverlay() { if (statusBarOverlay != null) { Log.d(TAG, "addStatusBarOverlay: already exists, skipping") @@ -511,6 +568,7 @@ class TapLockAccessibilityService : AccessibilityService() { doubleTapDetector.reset() } } + // Accessibility contract: performClick() after handling ACTION_UP manually. v.performClick() } } @@ -541,11 +599,13 @@ class TapLockAccessibilityService : AccessibilityService() { Log.d(TAG, "addStatusBarOverlay: added, height=${statusBarHeight}px") } + /** Re-evaluates edge and corner overlays together. */ private fun updateInteractiveZoneOverlays() { updateEdgeOverlays() updateCornerOverlays() } + /** Adds, updates, or removes each edge overlay based on its mode and the keyguard state. */ private fun updateEdgeOverlays() { val prefs = getPrefs() val leftEnabled = getZoneMode(prefs, getEdgeModeKey(EdgeZoneSide.LEFT)) != TapZoneMode.OFF @@ -569,6 +629,7 @@ class TapLockAccessibilityService : AccessibilityService() { updateEdgeOverlayTouchability() } + /** Adds, updates, or removes each corner overlay based on its mode and the keyguard state. */ private fun updateCornerOverlays() { val prefs = getPrefs() val canShowCornerZones = shouldShowInteractiveZones() @@ -584,6 +645,7 @@ class TapLockAccessibilityService : AccessibilityService() { updateCornerOverlayTouchability() } + /** Ensures a single corner overlay matches its [enabled] and [canShow] state. */ private fun updateCornerOverlay( position: CornerZonePosition, enabled: Boolean, @@ -597,6 +659,10 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** + * Creates an invisible overlay along one screen edge. An inward horizontal swipe triggers + * BACK, any other movement past slop cancels the gesture, and clean taps feed the detector. + */ private fun addEdgeOverlay(side: EdgeZoneSide) { if (getEdgeOverlay(side) != null) { return @@ -640,6 +706,7 @@ class TapLockAccessibilityService : AccessibilityService() { if (!gestureHandled) { handleEdgeTap(side, downTimeMs) } + // Accessibility contract: performClick() after handling ACTION_UP manually. v.performClick() } @@ -671,6 +738,7 @@ class TapLockAccessibilityService : AccessibilityService() { Log.d(TAG, "edgeOverlay: added side=$side, frame=$frame") } + /** Creates an invisible overlay in one screen corner; only movement-free taps feed the detector. */ private fun addCornerOverlay(position: CornerZonePosition) { if (getCornerOverlay(position) != null) { return @@ -710,6 +778,7 @@ class TapLockAccessibilityService : AccessibilityService() { if (!gestureHandled) { handleCornerTap(position, downTimeMs) } + // Accessibility contract: performClick() after handling ACTION_UP manually. v.performClick() } @@ -741,6 +810,7 @@ class TapLockAccessibilityService : AccessibilityService() { Log.d(TAG, "cornerOverlay: added position=$position, frame=$frame") } + /** Re-applies the edge overlay frame when preferences or screen bounds change. */ private fun updateEdgeOverlayLayout(side: EdgeZoneSide) { val overlay = getEdgeOverlay(side) ?: return val wm = getSystemService(WINDOW_SERVICE) as WindowManager @@ -758,11 +828,13 @@ class TapLockAccessibilityService : AccessibilityService() { params.x = frame.x params.y = frame.y wm.updateViewLayout(overlay, params) + // Frame changed: reset so a tap in the old zone can't pair with one in the new zone. getEdgeDetector(side).reset() Log.d(TAG, "edgeOverlay: updated side=$side, frame=$frame") } } + /** Re-applies the corner overlay frame when preferences or screen bounds change. */ private fun updateCornerOverlayLayout(position: CornerZonePosition) { val overlay = getCornerOverlay(position) ?: return val wm = getSystemService(WINDOW_SERVICE) as WindowManager @@ -780,17 +852,20 @@ class TapLockAccessibilityService : AccessibilityService() { params.x = frame.x params.y = frame.y wm.updateViewLayout(overlay, params) + // Frame changed: reset so a tap in the old zone can't pair with one in the new zone. getCornerDetector(position).reset() Log.d(TAG, "cornerOverlay: updated position=$position, frame=$frame") } } + /** Recomputes touchability for both edge overlays. */ private fun updateEdgeOverlayTouchability() { val touchDisabled = isCurrentAppExcluded() || isTapLockForeground() updateEdgeOverlayTouchability(EdgeZoneSide.LEFT, touchDisabled) updateEdgeOverlayTouchability(EdgeZoneSide.RIGHT, touchDisabled) } + /** Recomputes touchability for all four corner overlays. */ private fun updateCornerOverlayTouchability() { val touchDisabled = isCurrentAppExcluded() || isTapLockForeground() updateCornerOverlayTouchability(CornerZonePosition.TOP_LEFT, touchDisabled) @@ -799,6 +874,7 @@ class TapLockAccessibilityService : AccessibilityService() { updateCornerOverlayTouchability(CornerZonePosition.BOTTOM_RIGHT, touchDisabled) } + /** Toggles FLAG_NOT_TOUCHABLE on one edge overlay so touches pass through when suppressed. */ private fun updateEdgeOverlayTouchability(side: EdgeZoneSide, touchDisabled: Boolean) { val overlay = getEdgeOverlay(side) ?: return val wm = getSystemService(WINDOW_SERVICE) as WindowManager @@ -819,6 +895,7 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Toggles FLAG_NOT_TOUCHABLE on one corner overlay so touches pass through when suppressed. */ private fun updateCornerOverlayTouchability( position: CornerZonePosition, touchDisabled: Boolean @@ -842,6 +919,7 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Refreshes the tracked foreground app and recomputes touchability for every overlay. */ private fun updateOverlayTouchability() { refreshForegroundPackage() updateStatusBarOverlayTouchability() @@ -849,6 +927,10 @@ class TapLockAccessibilityService : AccessibilityService() { updateCornerOverlayTouchability() } + /** + * Enables or disables touch on the status bar overlay based on lock screen state, app + * exclusion, status bar visibility, and notification shade expansion. + */ private fun updateStatusBarOverlayTouchability() { val overlay = statusBarOverlay ?: return val prefs = getPrefs() @@ -890,6 +972,10 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** + * Resizes the status bar overlay for the current keyguard state: full lock-zone frame while + * locked, status bar (or camera-area) frame while unlocked, and a minimal strip otherwise. + */ private fun updateOverlayForLockScreen() { val km = getSystemService(KEYGUARD_SERVICE) as KeyguardManager val locked = km.isKeyguardLocked @@ -942,6 +1028,7 @@ class TapLockAccessibilityService : AccessibilityService() { params.x = newFrame.xPx params.y = newFrame.yPx wm.updateViewLayout(overlay, params) + // Frame changed: reset so a tap in the old zone can't pair with one in the new zone. doubleTapDetector.reset() Log.d( TAG, @@ -957,6 +1044,7 @@ class TapLockAccessibilityService : AccessibilityService() { updateInteractiveZoneOverlays() } + /** Builds the lock screen tap zone frame from the configured size and vertical offset. */ private fun getLockScreenOverlayFrame(): LockZoneFrame { val wm = getSystemService(WINDOW_SERVICE) as WindowManager val screenHeight = wm.currentWindowMetrics.bounds.height() @@ -974,6 +1062,10 @@ class TapLockAccessibilityService : AccessibilityService() { ) } + /** + * Builds the unlocked overlay frame: the full status bar strip, or just the camera cutout + * area when the camera-area-only preference is set. + */ private fun getUnlockedStatusBarOverlayFrame( statusBarHeight: Int = getStatusBarHeight() ): StatusBarOverlayFrame { @@ -1025,6 +1117,10 @@ class TapLockAccessibilityService : AccessibilityService() { ) } + /** + * Handles a tap on the lock screen zone: taps on interactive elements (notifications, + * shortcuts) are forwarded as clicks; only taps on empty space count toward the double tap. + */ private fun handleLockScreenTap(tapTimeMs: Long, x: Float, y: Float) { val clickedNode = findClickableNodeAt(x.toInt(), y.toInt()) if (clickedNode != null) { @@ -1037,6 +1133,7 @@ class TapLockAccessibilityService : AccessibilityService() { handleStatusBarTap(tapTimeMs) } + /** Finds the clickable accessibility node under screen point ([x], [y]), if any. */ private fun findClickableNodeAt(x: Int, y: Int): AccessibilityNodeInfo? { val nodeRect = Rect() for (window in windows) { @@ -1055,6 +1152,10 @@ class TapLockAccessibilityService : AccessibilityService() { return null } + /** + * Depth-first search for the deepest clickable node containing ([x], [y]); children win over + * parents so the most specific interactive element receives the forwarded click. + */ private fun findDeepestClickableNode( node: AccessibilityNodeInfo, x: Int, @@ -1080,8 +1181,10 @@ class TapLockAccessibilityService : AccessibilityService() { return null } + /** True when a system window is docked at the top of the screen (the status bar is shown). */ private fun isStatusBarVisible(): Boolean { val allWindows = windows + // No window info yet: assume visible rather than silently disabling the zone. if (allWindows.isEmpty()) return true val rect = Rect() return allWindows.any { window -> @@ -1092,6 +1195,7 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** True when System UI is showing the notification shade or quick settings. */ private fun isNotificationShadeExpanded(): Boolean { return windows.any { window -> if (window.type != AccessibilityWindowInfo.TYPE_SYSTEM) return@any false @@ -1104,12 +1208,14 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Matches window class/title names System UI uses for the shade across OEM variants. */ private fun isNotificationShadeIdentifier(value: String): Boolean { return value.contains("NotificationShade", ignoreCase = true) || value.contains("NotificationPanel", ignoreCase = true) || value.contains("QuickSettings", ignoreCase = true) } + /** Removes the status bar overlay and clears any pending first tap. */ private fun removeStatusBarOverlay() { statusBarOverlay?.let { val wm = getSystemService(WINDOW_SERVICE) as WindowManager @@ -1119,11 +1225,13 @@ class TapLockAccessibilityService : AccessibilityService() { doubleTapDetector.reset() } + /** Removes both edge overlays. */ private fun removeEdgeOverlays() { removeEdgeOverlay(EdgeZoneSide.LEFT) removeEdgeOverlay(EdgeZoneSide.RIGHT) } + /** Removes all four corner overlays. */ private fun removeCornerOverlays() { removeCornerOverlay(CornerZonePosition.TOP_LEFT) removeCornerOverlay(CornerZonePosition.TOP_RIGHT) @@ -1131,6 +1239,7 @@ class TapLockAccessibilityService : AccessibilityService() { removeCornerOverlay(CornerZonePosition.BOTTOM_RIGHT) } + /** Removes one edge overlay and resets its detector. */ private fun removeEdgeOverlay(side: EdgeZoneSide) { val overlay = getEdgeOverlay(side) ?: return val wm = getSystemService(WINDOW_SERVICE) as WindowManager @@ -1139,6 +1248,7 @@ class TapLockAccessibilityService : AccessibilityService() { getEdgeDetector(side).reset() } + /** Removes one corner overlay and resets its detector. */ private fun removeCornerOverlay(position: CornerZonePosition) { val overlay = getCornerOverlay(position) ?: return val wm = getSystemService(WINDOW_SERVICE) as WindowManager @@ -1147,6 +1257,10 @@ class TapLockAccessibilityService : AccessibilityService() { getCornerDetector(position).reset() } + /** + * Counts a tap on the status bar / lock screen zone toward the configured lock trigger, + * unless the current foreground app is excluded. + */ private fun handleStatusBarTap(tapTimeMs: Long) { refreshForegroundPackage() if (isCurrentAppExcluded()) { @@ -1161,6 +1275,7 @@ class TapLockAccessibilityService : AccessibilityService() { maybeTriggerLock(doubleTapDetector, tapTimeMs, source, mode) } + /** Counts a tap on an edge zone unless the app is excluded or TapLock itself is foreground. */ private fun handleEdgeTap(side: EdgeZoneSide, tapTimeMs: Long) { refreshForegroundPackage() val detector = getEdgeDetector(side) @@ -1174,6 +1289,7 @@ class TapLockAccessibilityService : AccessibilityService() { maybeTriggerLock(detector, tapTimeMs, "edge:$side", mode) } + /** Counts a tap on a corner zone unless the app is excluded or TapLock itself is foreground. */ private fun handleCornerTap(position: CornerZonePosition, tapTimeMs: Long) { refreshForegroundPackage() val detector = getCornerDetector(position) @@ -1190,11 +1306,13 @@ class TapLockAccessibilityService : AccessibilityService() { maybeTriggerLock(detector, tapTimeMs, "corner:$position", mode) } + /** Maps an edge [side] to its mode preference key. */ private fun getEdgeModeKey(side: EdgeZoneSide): Int = when (side) { EdgeZoneSide.LEFT -> R.string.left_edge_mode EdgeZoneSide.RIGHT -> R.string.right_edge_mode } + /** Maps a corner [position] to its mode preference key. */ private fun getCornerModeKey(position: CornerZonePosition): Int = when (position) { CornerZonePosition.TOP_LEFT -> R.string.top_left_corner_mode CornerZonePosition.TOP_RIGHT -> R.string.top_right_corner_mode @@ -1202,6 +1320,10 @@ class TapLockAccessibilityService : AccessibilityService() { CornerZonePosition.BOTTOM_RIGHT -> R.string.bottom_right_corner_mode } + /** + * Applies the zone [mode] to a tap: locks immediately for single-tap zones, otherwise feeds + * [detector] and locks when it reports a completed double tap. + */ private fun maybeTriggerLock( detector: DoubleTapDetector, tapTimeMs: Long, @@ -1224,11 +1346,13 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Locks with the user's vibration and delay settings applied. */ private fun performConfiguredLock(prefs: SharedPreferences) { val vibrateOnLock = prefs.getBoolean(getString(R.string.vibrate_on_lock), true) val lockDelay = prefs.getInt(getString(R.string.lock_delay_ms), 0).toLong() if (vibrateOnLock) { VibrationHelper.vibrate(this, VibrationHelper.fromPrefs(this)) + // Extra 100ms lets the vibration play before the screen turns off. Handler(Looper.getMainLooper()).postDelayed({ lockScreen() }, 100 + lockDelay) } else if (lockDelay > 0) { Handler(Looper.getMainLooper()).postDelayed({ lockScreen() }, lockDelay) @@ -1237,6 +1361,7 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Status bar height from window insets, with a 24dp fallback when insets report zero. */ private fun getStatusBarHeight(): Int { val wm = getSystemService(WINDOW_SERVICE) as WindowManager val insets = wm.currentWindowMetrics.windowInsets @@ -1250,10 +1375,15 @@ class TapLockAccessibilityService : AccessibilityService() { return fallback } + /** Base window flags shared by all tap-zone overlays. */ private fun createOverlayBaseFlags(): Int = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + /** + * Builds an edge overlay frame from preferences, deriving top/bottom offsets from the legacy + * coverage preference when the newer offset keys are unset. + */ private fun createEdgeFrame(side: EdgeZoneSide): EdgeZoneFrame { val prefs = getPrefs() val widthDp = prefs.getInt( @@ -1286,6 +1416,7 @@ class TapLockAccessibilityService : AccessibilityService() { ) } + /** Builds a corner overlay frame from the configured size. */ private fun createCornerFrame(position: CornerZonePosition): EdgeZoneFrame { val prefs = getPrefs() val sizeDp = prefs.getInt( @@ -1302,18 +1433,22 @@ class TapLockAccessibilityService : AccessibilityService() { ) } + /** Edge/corner zones are hidden while the device is locked to avoid blocking keyguard UI. */ private fun shouldShowInteractiveZones(): Boolean { return !isDeviceLocked() } + /** True while the keyguard is showing. */ private fun isDeviceLocked(): Boolean = (getSystemService(KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked + /** Returns the overlay view for the given edge [side], if attached. */ private fun getEdgeOverlay(side: EdgeZoneSide): View? = when (side) { EdgeZoneSide.LEFT -> leftEdgeOverlay EdgeZoneSide.RIGHT -> rightEdgeOverlay } + /** Returns the overlay view for the given corner [position], if attached. */ private fun getCornerOverlay(position: CornerZonePosition): View? = when (position) { CornerZonePosition.TOP_LEFT -> topLeftCornerOverlay CornerZonePosition.TOP_RIGHT -> topRightCornerOverlay @@ -1321,6 +1456,7 @@ class TapLockAccessibilityService : AccessibilityService() { CornerZonePosition.BOTTOM_RIGHT -> bottomRightCornerOverlay } + /** Stores the overlay view for the given edge [side]. */ private fun setEdgeOverlay(side: EdgeZoneSide, overlay: View?) { when (side) { EdgeZoneSide.LEFT -> leftEdgeOverlay = overlay @@ -1328,6 +1464,7 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Stores the overlay view for the given corner [position]. */ private fun setCornerOverlay(position: CornerZonePosition, overlay: View?) { when (position) { CornerZonePosition.TOP_LEFT -> topLeftCornerOverlay = overlay @@ -1337,11 +1474,13 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** Returns the per-side detector; each zone counts double taps independently. */ private fun getEdgeDetector(side: EdgeZoneSide): DoubleTapDetector = when (side) { EdgeZoneSide.LEFT -> leftEdgeDoubleTapDetector EdgeZoneSide.RIGHT -> rightEdgeDoubleTapDetector } + /** Returns the per-corner detector; each zone counts double taps independently. */ private fun getCornerDetector(position: CornerZonePosition): DoubleTapDetector = when (position) { CornerZonePosition.TOP_LEFT -> topLeftCornerDoubleTapDetector CornerZonePosition.TOP_RIGHT -> topRightCornerDoubleTapDetector @@ -1349,6 +1488,7 @@ class TapLockAccessibilityService : AccessibilityService() { CornerZonePosition.BOTTOM_RIGHT -> bottomRightCornerDoubleTapDetector } + /** True for an inward horizontal swipe past [touchSlop], which is forwarded as BACK. */ private fun shouldTriggerBackSwipe( side: EdgeZoneSide, dx: Float, @@ -1364,6 +1504,10 @@ class TapLockAccessibilityService : AccessibilityService() { } } + /** + * True when TapLock's own UI is in the foreground, so zone taps don't fire while the user is + * adjusting settings. + */ private fun isTapLockForeground(): Boolean { if (rootInActiveWindow?.packageName?.toString() == packageName) { return true @@ -1379,9 +1523,14 @@ class TapLockAccessibilityService : AccessibilityService() { .any { foregroundPackage -> foregroundPackage == packageName } } + /** True when the tracked foreground app is excluded; exclusion never applies on the lock screen. */ private fun isCurrentAppExcluded(): Boolean = !isOnLockScreen && TapLockAppRules.isPackageExcluded(this, currentForegroundPackage) + /** + * Updates the tracked foreground package (optionally seeded by [eventPackage]) and returns + * true when it changed. + */ private fun refreshForegroundPackage(eventPackage: String? = null): Boolean { val previousPackage = currentForegroundPackage val resolvedPackage = resolveForegroundPackage(eventPackage) @@ -1394,6 +1543,10 @@ class TapLockAccessibilityService : AccessibilityService() { return currentForegroundPackage != previousPackage } + /** + * Best-effort foreground package lookup: active window root, then focused app windows, then + * the event's package, falling back to the last known value. + */ private fun resolveForegroundPackage(eventPackage: String? = null): String? { val activeRootPackage = TapLockAppRules.sanitizeTrackedPackage( this, diff --git a/app/src/main/java/com/ah/taplock/TapLockAppRules.kt b/app/src/main/java/com/ah/taplock/TapLockAppRules.kt index 0385d0d..3b49e8c 100644 --- a/app/src/main/java/com/ah/taplock/TapLockAppRules.kt +++ b/app/src/main/java/com/ah/taplock/TapLockAppRules.kt @@ -6,23 +6,31 @@ import android.content.SharedPreferences import androidx.core.content.edit import java.util.Locale +/** A launchable app shown in the exclusion picker: [packageName] plus its display [label]. */ data class TapLockAppInfo( val packageName: String, val label: String ) +/** + * Per-app exclusion rules. The accessibility service records the last foreground package here so + * rootless entry points (widget, tile) can honor exclusions even without a live service instance. + */ object TapLockAppRules { private const val SYSTEM_UI_PACKAGE = "com.android.systemui" private const val ANDROID_PACKAGE = "android" + /** Shared preferences file used by the whole app. */ private fun prefs(context: Context): SharedPreferences = context.getSharedPreferences(context.getString(R.string.shared_pref_name), Context.MODE_PRIVATE) + /** Packages the user excluded from tap-to-lock. */ fun getExcludedPackages(context: Context): Set { val key = context.getString(R.string.excluded_apps) return prefs(context).getStringSet(key, emptySet())?.toSet() ?: emptySet() } + /** Replaces the stored exclusion set with [packages]. */ fun setExcludedPackages(context: Context, packages: Set) { val key = context.getString(R.string.excluded_apps) prefs(context).edit { @@ -30,6 +38,7 @@ object TapLockAppRules { } } + /** Adds [packageName] to the exclusion set, or removes it if already present. */ fun toggleExcludedPackage(context: Context, packageName: String) { val updated = getExcludedPackages(context).toMutableSet().apply { if (!add(packageName)) { @@ -39,6 +48,10 @@ object TapLockAppRules { setExcludedPackages(context, updated) } + /** + * Persists [packageName] as the last real foreground app, skipping the write when it is not + * trackable or unchanged (SharedPreferences writes are synchronous and this runs per event). + */ fun updateForegroundPackage(context: Context, packageName: String?) { val trackedPackage = sanitizeTrackedPackage(context, packageName) ?: return @@ -51,6 +64,10 @@ object TapLockAppRules { } } + /** + * Filters [packageName] down to something worth tracking: drops blanks, TapLock itself, and + * system UI overlays that would otherwise mask the app actually behind them. + */ fun sanitizeTrackedPackage(context: Context, packageName: String?): String? { if (packageName.isNullOrBlank()) return null if (packageName == context.packageName) return null @@ -58,15 +75,22 @@ object TapLockAppRules { return packageName } + /** Last tracked foreground package, or null when nothing has been recorded yet. */ fun getForegroundPackage(context: Context): String? = prefs(context).getString(context.getString(R.string.last_foreground_package), null) + /** True when the last tracked foreground app is in the exclusion set. */ fun isCurrentAppExcluded(context: Context): Boolean = isPackageExcluded(context, getForegroundPackage(context)) + /** True when [packageName] is non-blank and in the exclusion set. */ fun isPackageExcluded(context: Context, packageName: String?): Boolean = !packageName.isNullOrBlank() && getExcludedPackages(context).contains(packageName) + /** + * Loads all launcher-visible apps (excluding TapLock) as [TapLockAppInfo], deduplicated and + * sorted case-insensitively by label for the exclusion picker. + */ fun loadLaunchableApps(context: Context): List { val packageManager = context.packageManager val intent = Intent(Intent.ACTION_MAIN).apply { @@ -95,6 +119,7 @@ object TapLockAppRules { ) } + /** Human-readable label for [packageName], falling back to the package name itself. */ fun resolveAppLabel(context: Context, packageName: String): String { val packageManager = context.packageManager return try { diff --git a/app/src/main/java/com/ah/taplock/TapLockBatteryOptimization.kt b/app/src/main/java/com/ah/taplock/TapLockBatteryOptimization.kt index 41db908..989c779 100644 --- a/app/src/main/java/com/ah/taplock/TapLockBatteryOptimization.kt +++ b/app/src/main/java/com/ah/taplock/TapLockBatteryOptimization.kt @@ -4,16 +4,20 @@ import android.content.Intent import android.net.Uri import android.provider.Settings +/** Intent builders for the battery-optimization exemption flow (keeps the service alive). */ object TapLockBatteryOptimization { const val REQUEST_ACTION = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS const val SETTINGS_ACTION = Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS + /** Builds the `package:` URI string the settings actions expect for [packageName]. */ fun packageUriString(packageName: String): String = "package:$packageName" + /** Intent showing the system dialog that asks to exempt [packageName] from optimization. */ fun requestIntent(packageName: String): Intent = Intent(REQUEST_ACTION).apply { data = Uri.parse(packageUriString(packageName)) } + /** Intent opening the full battery-optimization settings list as a manual fallback. */ fun settingsIntent(): Intent = Intent(SETTINGS_ACTION) } diff --git a/app/src/main/java/com/ah/taplock/TapLockEdgeZones.kt b/app/src/main/java/com/ah/taplock/TapLockEdgeZones.kt index 12649ac..802af23 100644 --- a/app/src/main/java/com/ah/taplock/TapLockEdgeZones.kt +++ b/app/src/main/java/com/ah/taplock/TapLockEdgeZones.kt @@ -2,11 +2,13 @@ package com.ah.taplock import kotlin.math.roundToInt +/** Which screen edge an edge tap zone is anchored to. */ enum class EdgeZoneSide { LEFT, RIGHT } +/** Which screen corner a corner tap zone occupies. */ enum class CornerZonePosition { TOP_LEFT, TOP_RIGHT, @@ -14,6 +16,7 @@ enum class CornerZonePosition { BOTTOM_RIGHT } +/** Resolved pixel geometry of an edge or corner tap zone overlay. */ data class EdgeZoneFrame( val widthPx: Int, val heightPx: Int, @@ -21,6 +24,11 @@ data class EdgeZoneFrame( val y: Int ) +/** + * Pure geometry for edge and corner tap zones. Edges are described by a dp width plus top/bottom + * offsets in percent of screen height; corners by a square dp size. Kept free of Android types so + * it is unit-testable. + */ object TapLockEdgeZones { const val DEFAULT_WIDTH_DP = 18 const val MIN_WIDTH_DP = 12 @@ -39,6 +47,10 @@ object TapLockEdgeZones { const val MIN_OFFSET_PERCENT = 0 const val MAX_OFFSET_PERCENT = 40 + /** + * Computes the frame of an edge zone on [side]: [widthDp] wide, spanning the screen height + * minus [topOffsetPercent] and [bottomOffsetPercent]. Inputs are clamped to supported ranges. + */ fun buildFrame( screenWidthPx: Int, screenHeightPx: Int, @@ -73,6 +85,7 @@ object TapLockEdgeZones { ) } + /** Computes the square frame of a corner zone of [sizeDp] anchored at [position]. */ fun buildCornerFrame( screenWidthPx: Int, screenHeightPx: Int, @@ -105,6 +118,10 @@ object TapLockEdgeZones { ) } + /** + * Migrates the legacy single "coverage" preference into centered top/bottom offsets, giving + * any odd remainder to the bottom. + */ fun deriveOffsetsFromCoverage(coveragePercent: Int): Pair { val clampedCoveragePercent = coveragePercent.coerceIn(MIN_COVERAGE_PERCENT, MAX_COVERAGE_PERCENT) diff --git a/app/src/main/java/com/ah/taplock/TapLockFeedback.kt b/app/src/main/java/com/ah/taplock/TapLockFeedback.kt index 085bd89..34e6ddf 100644 --- a/app/src/main/java/com/ah/taplock/TapLockFeedback.kt +++ b/app/src/main/java/com/ah/taplock/TapLockFeedback.kt @@ -4,27 +4,34 @@ import android.app.StatusBarManager import android.content.Context import android.widget.Toast +/** Centralized user-facing toasts so widget, tile, and service surfaces show identical wording. */ object TapLockFeedback { + /** Tells the user the lock was skipped because the foreground app is excluded. */ fun showAppExcluded(context: Context) { toast(context, R.string.app_exclusions_disabled_toast) } + /** Tells the user the accessibility service must be enabled before locking can work. */ fun showAccessibilityRequired(context: Context) { toast(context, R.string.accessibility_permission_required, Toast.LENGTH_LONG) } + /** Tells the user the root shell rejected the lock command (e.g. permission revoked). */ fun showRootLockFailed(context: Context) { toast(context, R.string.root_lock_failed, Toast.LENGTH_LONG) } + /** Tells the user the launcher does not support programmatic widget pinning. */ fun showWidgetPinUnsupported(context: Context) { toast(context, R.string.widget_pin_not_supported_toast, Toast.LENGTH_LONG) } + /** Tells the user the tile must be added manually (no StatusBarManager request support). */ fun showQuickSettingsAddUnsupported(context: Context) { toast(context, R.string.quick_settings_tile_manual_toast, Toast.LENGTH_LONG) } + /** Maps a StatusBarManager tile-add [result] code to the matching toast. */ fun showQuickSettingsAddResult(context: Context, result: Int) { when (result) { StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED -> @@ -37,6 +44,7 @@ object TapLockFeedback { } } + /** Shows [messageRes] as a toast for [duration]. */ private fun toast( context: Context, messageRes: Int, diff --git a/app/src/main/java/com/ah/taplock/TapLockFloatingButtonConfig.kt b/app/src/main/java/com/ah/taplock/TapLockFloatingButtonConfig.kt index 0f3461e..e17243a 100644 --- a/app/src/main/java/com/ah/taplock/TapLockFloatingButtonConfig.kt +++ b/app/src/main/java/com/ah/taplock/TapLockFloatingButtonConfig.kt @@ -1,5 +1,6 @@ package com.ah.taplock +/** Size and opacity bounds for the floating lock button, shared by settings UI and overlay. */ object TapLockFloatingButtonConfig { const val DEFAULT_SIZE_DP = 56 const val MIN_SIZE_DP = 40 @@ -9,8 +10,10 @@ object TapLockFloatingButtonConfig { const val MIN_OPACITY_PERCENT = 35 const val MAX_OPACITY_PERCENT = 100 + /** Clamps a button size [value] (dp) into the supported range. */ fun clampSizeDp(value: Int): Int = value.coerceIn(MIN_SIZE_DP, MAX_SIZE_DP) + /** Clamps a button opacity [value] (percent) into the supported range. */ fun clampOpacityPercent(value: Int): Int = value.coerceIn(MIN_OPACITY_PERCENT, MAX_OPACITY_PERCENT) } diff --git a/app/src/main/java/com/ah/taplock/TapLockLockZone.kt b/app/src/main/java/com/ah/taplock/TapLockLockZone.kt index 84d61fd..fb016ba 100644 --- a/app/src/main/java/com/ah/taplock/TapLockLockZone.kt +++ b/app/src/main/java/com/ah/taplock/TapLockLockZone.kt @@ -2,11 +2,13 @@ package com.ah.taplock import kotlin.math.roundToInt +/** Resolved geometry for the lock-screen tap zone overlay: [heightPx] tall, starting at [yPx]. */ data class LockZoneFrame( val heightPx: Int, val yPx: Int ) +/** Pure geometry for the lock-screen tap zone, expressed in percentages of screen height. */ object TapLockLockZone { const val DEFAULT_PERCENT = 66 const val MIN_PERCENT = 20 @@ -15,15 +17,22 @@ object TapLockLockZone { const val DEFAULT_TOP_OFFSET_PERCENT = 0 const val MIN_TOP_OFFSET_PERCENT = 0 + /** Clamps a zone height [value] into the supported percent range. */ fun clampPercent(value: Int): Int = value.coerceIn(MIN_PERCENT, MAX_PERCENT) + /** Largest top offset that still keeps a zone of [zonePercent] fully on screen. */ fun maxTopOffsetPercent(zonePercent: Int): Int = (100 - clampPercent(zonePercent)).coerceAtLeast(0) + /** Clamps a top offset [value] so the zone of [zonePercent] never extends past the bottom. */ fun clampTopOffsetPercent( value: Int, zonePercent: Int ): Int = value.coerceIn(MIN_TOP_OFFSET_PERCENT, maxTopOffsetPercent(zonePercent)) + /** + * Converts [zonePercent] and [topOffsetPercent] into pixel geometry for a screen of + * [screenHeightPx]. Inputs are clamped so the result is always a valid on-screen frame. + */ fun buildFrame( screenHeightPx: Int, zonePercent: Int, diff --git a/app/src/main/java/com/ah/taplock/TapLockStatusBarZone.kt b/app/src/main/java/com/ah/taplock/TapLockStatusBarZone.kt index 89e6657..93f78e2 100644 --- a/app/src/main/java/com/ah/taplock/TapLockStatusBarZone.kt +++ b/app/src/main/java/com/ah/taplock/TapLockStatusBarZone.kt @@ -2,26 +2,35 @@ package com.ah.taplock import kotlin.math.roundToInt +/** Display-cutout rectangle in pixels, as reported by the window insets. */ data class StatusBarCutoutBounds( val leftPx: Int, val topPx: Int, val rightPx: Int, val bottomPx: Int ) { + /** Guards against degenerate rects the platform occasionally reports. */ val isValid: Boolean get() = rightPx > leftPx && bottomPx > topPx } +/** Resolved horizontal slice of the status bar used as the tap zone. */ data class StatusBarZoneFrame( val widthPx: Int, val xPx: Int ) +/** Pure geometry for the "camera area" status-bar tap zone, centered on the display cutout. */ object TapLockStatusBarZone { const val CAMERA_AREA_FALLBACK_WIDTH_DP = 96 const val CAMERA_AREA_MIN_WIDTH_DP = 72 const val CAMERA_AREA_HORIZONTAL_PADDING_DP = 24 + /** + * Computes the tap zone around the camera cutout: the union of cutouts intersecting the + * status bar, padded and clamped to the screen. Falls back to a fixed-width centered zone on + * devices with no cutout (e.g. emulators, punch-hole-less screens). + */ fun buildCameraAreaFrame( screenWidthPx: Int, statusBarHeightPx: Int, @@ -66,6 +75,7 @@ object TapLockStatusBarZone { ) } + /** Converts [valueDp] to pixels at [density], never returning less than 1. */ private fun dpToPx(valueDp: Int, density: Float): Int = (valueDp * density).roundToInt().coerceAtLeast(1) } diff --git a/app/src/main/java/com/ah/taplock/TapLockTileService.kt b/app/src/main/java/com/ah/taplock/TapLockTileService.kt index 3e31b48..7570aea 100644 --- a/app/src/main/java/com/ah/taplock/TapLockTileService.kt +++ b/app/src/main/java/com/ah/taplock/TapLockTileService.kt @@ -11,25 +11,38 @@ import android.service.quicksettings.TileService import android.widget.Toast import androidx.core.content.edit +/** + * Quick Settings tile that locks the screen on tap. Mirrors the widget's lock strategy: live + * service instance first, root shell second, startService fallback, otherwise point the user at + * accessibility settings. Also tracks whether the tile is currently added, for the settings UI. + */ class TapLockTileService : TileService() { + /** Records that the tile was added and syncs its visual state. */ override fun onTileAdded() { super.onTileAdded() persistTileAdded(true) updateTileState() } + /** Records that the tile was removed from Quick Settings. */ override fun onTileRemoved() { persistTileAdded(false) super.onTileRemoved() } + /** Refreshes tile state each time the Quick Settings panel becomes visible. */ override fun onStartListening() { super.onStartListening() persistTileAdded(true) updateTileState() } + /** + * Locks the screen unless the foreground app is excluded, applying the configured vibration + * and lock delay. Falls back through root and startService paths when the service instance + * is unavailable, and opens accessibility settings as a last resort. + */ override fun onClick() { super.onClick() @@ -97,6 +110,7 @@ class TapLockTileService : TileService() { } } + /** Updates label, icon, and availability: usable when either lock method is ready. */ private fun updateTileState() { qsTile?.let { tile -> val isEnabled = isAccessibilityEnabled(this) || RootLock.isRootLockEnabled(this) @@ -112,6 +126,7 @@ class TapLockTileService : TileService() { } } + /** Persists [isAdded] so the settings screen can show whether the tile is in Quick Settings. */ private fun persistTileAdded(isAdded: Boolean) { getSharedPreferences(getString(R.string.shared_pref_name), MODE_PRIVATE) .edit { putBoolean(getString(R.string.quick_settings_tile_added), isAdded) } diff --git a/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt b/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt index 6d346f3..25d843b 100644 --- a/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt +++ b/app/src/main/java/com/ah/taplock/TapLockWidgetProvider.kt @@ -15,17 +15,28 @@ import android.widget.RemoteViews import android.widget.Toast import java.io.File +/** + * 1x1 home-screen widget that locks the device on tap. Built on RemoteViews (not Compose/Glance): + * taps arrive as self-addressed broadcasts, and double-tap state lives in the companion because + * the provider instance is recreated per broadcast. + */ class TapLockWidgetProvider : AppWidgetProvider() { companion object { private const val ACTION_WIDGET_TAP = "com.ah.taplock.widget.TAP" + // Static so the tap window survives across provider instances (one per broadcast). private val doubleTapDetector = DoubleTapDetector() + /** Number of TapLock widgets currently placed on the home screen. */ fun getWidgetCount(context: Context): Int = AppWidgetManager.getInstance(context).getAppWidgetIds( ComponentName(context, TapLockWidgetProvider::class.java) ).size + /** + * Forces every placed widget to re-render by broadcasting ACTION_APPWIDGET_UPDATE. Needed + * after style/icon preference changes because RemoteViews cache the last layout. + */ fun refreshAll(context: Context) { val ids = AppWidgetManager.getInstance(context).getAppWidgetIds( ComponentName(context, TapLockWidgetProvider::class.java) @@ -39,6 +50,7 @@ class TapLockWidgetProvider : AppWidgetProvider() { } } + /** Renders every widget in [appWidgetIds] with the current preferences. */ override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, @@ -49,6 +61,7 @@ class TapLockWidgetProvider : AppWidgetProvider() { } } + /** Routes the self-addressed [ACTION_WIDGET_TAP] broadcast to the tap handler. */ override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) @@ -65,6 +78,10 @@ class TapLockWidgetProvider : AppWidgetProvider() { } } + /** + * Builds and pushes the RemoteViews for one widget: background style, optional icon (custom + * PNG from app files or the launcher icon), and the tap PendingIntent. + */ private fun updateAppWidget( context: Context, appWidgetManager: AppWidgetManager, @@ -77,6 +94,8 @@ class TapLockWidgetProvider : AppWidgetProvider() { prefs.getString(context.getString(R.string.widget_style), null) ) + // RemoteViews can't swap a view's foreground at runtime, so the ripple toggle picks + // between two otherwise identical layouts. val layoutResId = if (rippleEnabled) R.layout.widget_layout else R.layout.widget_layout_no_ripple val views = RemoteViews(context.packageName, layoutResId).apply { setOnClickPendingIntent( @@ -100,6 +119,11 @@ class TapLockWidgetProvider : AppWidgetProvider() { appWidgetManager.updateAppWidget(appWidgetId, views) } + /** + * Applies the widget tap mode (single/double) and, once triggered, locks the screen unless + * the foreground app is excluded. Prefers the live service instance, then the root shell, + * then a startService fallback when the service is enabled but not yet bound. + */ private fun handleWidgetTap(context: Context) { val prefs = context.getSharedPreferences(context.getString(R.string.shared_pref_name), Context.MODE_PRIVATE) val mode = TapZoneMode.fromStored( @@ -143,6 +167,7 @@ class TapLockWidgetProvider : AppWidgetProvider() { } } + // Leave the 100 ms vibration lead-in so the haptic is felt before the screen dies. val totalDelay = (if (vibrateOnLock) 100L else 0L) + lockDelay if (totalDelay > 0) { Handler(Looper.getMainLooper()).postDelayed(lockRunnable, totalDelay) @@ -152,6 +177,7 @@ class TapLockWidgetProvider : AppWidgetProvider() { } } + /** PendingIntent that routes a tap on widget [appWidgetId] back to this provider. */ private fun getPendingSelfIntent(context: Context, appWidgetId: Int): PendingIntent { val intent = Intent(context, TapLockWidgetProvider::class.java).apply { this.action = ACTION_WIDGET_TAP diff --git a/app/src/main/java/com/ah/taplock/TapLockWidgetStyle.kt b/app/src/main/java/com/ah/taplock/TapLockWidgetStyle.kt index d03e2bf..9a0df1b 100644 --- a/app/src/main/java/com/ah/taplock/TapLockWidgetStyle.kt +++ b/app/src/main/java/com/ah/taplock/TapLockWidgetStyle.kt @@ -3,6 +3,7 @@ package com.ah.taplock import androidx.annotation.DrawableRes import androidx.annotation.StringRes +/** Visual style of the home-screen widget, pairing its settings label with a background drawable. */ enum class TapLockWidgetStyle( @field:StringRes val labelResId: Int, @field:DrawableRes val backgroundResId: Int @@ -27,6 +28,7 @@ enum class TapLockWidgetStyle( companion object { val default = TRANSPARENT + /** Parses a stored preference [value], tolerating null or stale names via [default]. */ fun fromStored(value: String?): TapLockWidgetStyle = entries.firstOrNull { it.name == value } ?: default } diff --git a/app/src/main/java/com/ah/taplock/TapZoneMode.kt b/app/src/main/java/com/ah/taplock/TapZoneMode.kt index 04dff8c..e6148d1 100644 --- a/app/src/main/java/com/ah/taplock/TapZoneMode.kt +++ b/app/src/main/java/com/ah/taplock/TapZoneMode.kt @@ -2,6 +2,7 @@ package com.ah.taplock import androidx.annotation.StringRes +/** How a tap zone triggers the lock: disabled, on a double tap, or on a single tap. */ enum class TapZoneMode(@field:StringRes val labelResId: Int) { OFF(R.string.tap_mode_off), DOUBLE_TAP(R.string.tap_mode_double_tap), @@ -10,6 +11,7 @@ enum class TapZoneMode(@field:StringRes val labelResId: Int) { companion object { val default = OFF + /** Parses a stored preference [value], tolerating null or stale names via [default]. */ fun fromStored(value: String?, default: TapZoneMode = OFF): TapZoneMode = entries.firstOrNull { it.name == value } ?: default } diff --git a/app/src/main/java/com/ah/taplock/Utils.kt b/app/src/main/java/com/ah/taplock/Utils.kt index 88f17ed..8d2a522 100644 --- a/app/src/main/java/com/ah/taplock/Utils.kt +++ b/app/src/main/java/com/ah/taplock/Utils.kt @@ -40,6 +40,7 @@ fun registerAdvancedProtectionCallback( return callback } +/** Removes a [callback] previously returned by [registerAdvancedProtectionCallback]. */ @RequiresApi(36) fun unregisterAdvancedProtectionCallback( context: Context, @@ -49,6 +50,10 @@ fun unregisterAdvancedProtectionCallback( manager.unregisterAdvancedProtectionCallback(callback) } +/** + * True when the TapLock accessibility service is currently enabled in system settings. Queries + * the live service list rather than Settings.Secure, so it reflects the actual bound state. + */ fun isAccessibilityEnabled(context: Context): Boolean { val accessibilityManager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager diff --git a/app/src/main/java/com/ah/taplock/VibrationHelper.kt b/app/src/main/java/com/ah/taplock/VibrationHelper.kt index efc3310..9f59e0a 100644 --- a/app/src/main/java/com/ah/taplock/VibrationHelper.kt +++ b/app/src/main/java/com/ah/taplock/VibrationHelper.kt @@ -7,14 +7,17 @@ import android.os.VibrationAttributes import android.os.VibrationEffect import android.os.Vibrator +/** User-selectable haptic strength: a one-shot vibration of [durationMs] at [amplitude] (1–255). */ enum class VibrationPattern(val durationMs: Long, val amplitude: Int) { LIGHT(30, 40), MEDIUM(50, 80), STRONG(80, 255) } +/** API-level-aware haptic feedback for lock actions (Android 13+ vs older attribute APIs). */ object VibrationHelper { + /** Reads the stored pattern preference, falling back to [VibrationPattern.MEDIUM]. */ fun fromPrefs(context: Context): VibrationPattern { val prefs = context.getSharedPreferences( context.getString(R.string.shared_pref_name), Context.MODE_PRIVATE @@ -27,6 +30,11 @@ object VibrationHelper { } } + /** + * Plays [pattern] with ALARM usage so it fires even in do-not-disturb / silent ring modes. + * Android 13+ uses VibrationAttributes; older releases need the deprecated AudioAttributes + * overload — both branches must be kept in sync. + */ fun vibrate(context: Context, pattern: VibrationPattern) { val vibrator = context.getSystemService(Vibrator::class.java) val effect = VibrationEffect.createOneShot(pattern.durationMs, pattern.amplitude) diff --git a/app/src/main/java/com/ah/taplock/WidgetComponents.kt b/app/src/main/java/com/ah/taplock/WidgetComponents.kt index aad4984..0a5d900 100644 --- a/app/src/main/java/com/ah/taplock/WidgetComponents.kt +++ b/app/src/main/java/com/ah/taplock/WidgetComponents.kt @@ -21,6 +21,10 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +/** + * Settings preview of the home-screen widget in the chosen [style]. Colors approximate the real + * widget background drawables, which RemoteViews render on the launcher and can't be reused here. + */ @Composable fun WidgetStylePreview( style: TapLockWidgetStyle, @@ -83,6 +87,10 @@ fun WidgetStylePreview( } } +/** + * Settings preview of the floating lock button at [sizeDp] and [opacityPercent], mirroring the + * proportions the accessibility overlay uses (icon at ~68% of the button size). + */ @Composable fun FloatingButtonPreview( iconBitmap: ImageBitmap, diff --git a/app/src/main/java/com/ah/taplock/ui/theme/Theme.kt b/app/src/main/java/com/ah/taplock/ui/theme/Theme.kt index 543d4b7..9e6bd31 100644 --- a/app/src/main/java/com/ah/taplock/ui/theme/Theme.kt +++ b/app/src/main/java/com/ah/taplock/ui/theme/Theme.kt @@ -69,6 +69,10 @@ private val LightColorScheme = lightColorScheme( onErrorContainer = LightOnErrorContainer ) +/** + * App-wide Material 3 theme. [darkTheme] follows the system by default; [dynamicColor] can opt + * into Material You wallpaper-based palettes instead of the fixed brand colors. + */ @Composable fun TapLockTheme( darkTheme: Boolean = isSystemInDarkTheme(), From 262934c4915b3d29179db419b73d2086dace926f Mon Sep 17 00:00:00 2001 From: Nello Carotenuto Date: Wed, 2 Sep 2026 00:13:38 +0200 Subject: [PATCH 4/6] Add battery optimization prompt and related strings for improved accessibility --- .../main/java/com/ah/taplock/MainActivity.kt | 54 ++++++++++++++++++- app/src/main/res/values-es/strings.xml | 6 +++ app/src/main/res/values/strings.xml | 7 +++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/ah/taplock/MainActivity.kt b/app/src/main/java/com/ah/taplock/MainActivity.kt index bf5a177..8049164 100644 --- a/app/src/main/java/com/ah/taplock/MainActivity.kt +++ b/app/src/main/java/com/ah/taplock/MainActivity.kt @@ -169,6 +169,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val hasCompletedOnboardingKey = stringResource(R.string.has_completed_onboarding) val lockMethodKey = stringResource(R.string.lock_method) val rootModePromptShownKey = stringResource(R.string.root_mode_prompt_shown) + val batteryPromptShownKey = stringResource(R.string.battery_prompt_shown) val rootAccessDeniedMsg = stringResource(R.string.root_access_denied) val lockDelayMsKey = stringResource(R.string.lock_delay_ms) val lockCountKey = stringResource(R.string.lock_count) @@ -195,6 +196,8 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName)) } + var showBatteryPrompt by remember { mutableStateOf(false) } + var isDeviceRooted by remember { mutableStateOf(false) } var lockMethodRoot by remember { mutableStateOf(false) } var showRootModePrompt by remember { mutableStateOf(false) } @@ -589,6 +592,10 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { isLoadingApps = false lockMethodRoot = prefs.getString(lockMethodKey, null) == RootLock.LOCK_METHOD_ROOT + // Battery optimization can suspend the accessibility service, so prompt once for the + // exemption as soon as the service is active. Rendering order puts it before root. + showBatteryPrompt = isAccessibilityEnabled && isBatteryOptimized && + !prefs.getBoolean(batteryPromptShownKey, false) // su detection touches the filesystem, so keep it off the main thread. val rooted = withContext(Dispatchers.IO) { RootLock.isDeviceRooted() } isDeviceRooted = rooted @@ -727,6 +734,10 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val prefs = context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) lockCount = prefs.getInt(lockCountKey, 0) lockMethodRoot = prefs.getString(lockMethodKey, null) == RootLock.LOCK_METHOD_ROOT + // Re-evaluate on every resume so the prompt fires right after the user comes + // back from enabling the accessibility service. + showBatteryPrompt = isAccessibilityEnabled && isBatteryOptimized && + !prefs.getBoolean(batteryPromptShownKey, false) showIcon = prefs.getBoolean(showWidgetIconKey, false) widgetRippleEnabled = prefs.getBoolean(widgetRippleEnabledKey, true) widgetStyle = TapLockWidgetStyle.fromStored(prefs.getString(widgetStyleKey, null)) @@ -2007,8 +2018,49 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { ) } + // Shown once the accessibility service is active and before the root prompt, so the + // permission dialogs appear in setup order instead of stacking. + if (showBatteryPrompt && !showOnboarding) { + /** Marks the prompt as handled so it never re-fires on later launches. */ + fun dismissBatteryPrompt() { + context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) + .edit { putBoolean(batteryPromptShownKey, true) } + showBatteryPrompt = false + } + + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.battery_prompt_title)) }, + text = { Text(stringResource(R.string.battery_prompt_body)) }, + confirmButton = { + TextButton( + onClick = { + dismissBatteryPrompt() + // Direct system exemption dialog (REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + // is in the manifest); fall back to the full settings list. + runCatching { + context.startActivity( + TapLockBatteryOptimization.requestIntent(context.packageName) + ) + }.onFailure { + context.startActivity(TapLockBatteryOptimization.settingsIntent()) + } + }, + modifier = Modifier.testTag("button_battery_prompt_allow") + ) { + Text(stringResource(R.string.battery_prompt_allow)) + } + }, + dismissButton = { + TextButton(onClick = { dismissBatteryPrompt() }) { + Text(stringResource(R.string.not_now)) + } + } + ) + } + // Defer the root prompt until onboarding finishes so the dialogs don't stack. - if (showRootModePrompt && !showOnboarding) { + if (showRootModePrompt && !showOnboarding && !showBatteryPrompt) { AlertDialog( onDismissRequest = {}, title = { Text(stringResource(R.string.root_mode_prompt_title)) }, diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f233337..a4eb2d4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -69,6 +69,12 @@ Sin restricciones (recomendado) Corregir Gestionar + Desactivar la optimización de batería + Android está aplicando la optimización de batería a + TapLock. Esto puede suspender el servicio de accesibilidad y hacer que las zonas de toque, + el widget y el mosaico de ajustes rápidos dejen de funcionar al cabo de un tiempo. Permite + que TapLock funcione sin restricciones para que el bloqueo sea fiable. + Permitir Tiempo límite de doble toque: %dms Ligera Media diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7a847ba..28fced2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -96,6 +96,13 @@ Unrestricted (recommended) Fix Manage + battery_optimization_prompt_shown + Disable battery optimization + Android is currently applying battery optimization to + TapLock. This can suspend the accessibility service and make tap zones, the widget, and + the Quick Settings tile stop working after a while. Allow TapLock to run without + restrictions to keep locking reliable. + Allow Double Tap Timeout: %dms vibration_pattern From a88b8ff333a79015e200cffddea65d9d3440c4e7 Mon Sep 17 00:00:00 2001 From: Nello Carotenuto Date: Wed, 2 Sep 2026 00:21:00 +0200 Subject: [PATCH 5/6] Update docs with new features --- CLAUDE.md | 2 ++ README.md | 23 +++++++++++++++---- .../metadata/android/en-US/changelogs/20.txt | 3 +++ .../android/en-US/full_description.txt | 1 + .../metadata/android/es-ES/changelogs/20.txt | 3 +++ .../android/es-ES/full_description.txt | 1 + 6 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/20.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/20.txt diff --git a/CLAUDE.md b/CLAUDE.md index 10492a6..335f2fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ Single `:app` module. All source in `app/src/main/java/com/ah/taplock/`: - **TapLockAccessibilityService.kt** — Core service: accessibility overlays, floating lock button, and double-tap detection on status bar and lock screen - **TapLockWidgetProvider.kt** — RemoteViews-based 1x1 widget - **TapLockTileService.kt** — Quick Settings tile +- **RootLock.kt** — Optional root lock method: detects rooted devices, verifies su access, and locks by injecting KEYCODE_POWER via a persistent root shell (no lock-screen flicker); callers fall back to the accessibility lock on failure - **DoubleTapDetector.kt** — Time-window tap detection with injectable clock - **VibrationHelper.kt** — API-level-aware vibration (Android 13+ vs older) - **Utils.kt** — Accessibility state check utility @@ -42,3 +43,4 @@ Kotlin official style (`kotlin.code.style=official`). No detekt/ktlint — use ` - Widget uses RemoteViews (no Compose). Custom icon requires manual cache invalidation via ACTION_APPWIDGET_UPDATE broadcast. - VibrationHelper branches on Build.VERSION for Android 13+. Both paths must be maintained. - SharedPreferences are synchronous throughout — changes immediately trigger recomposition via preference listener. +- Root lock: pref `lock_method` (ACCESSIBILITY|ROOT), prompt-once flag `root_mode_prompt_shown`. The su shell is reused across locks; widget/tile can lock via `RootLock.performRootLock` without a live service instance. Every root path must keep its accessibility fallback. diff --git a/README.md b/README.md index d73edc5..7a39a5c 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,13 @@ [![Download on Google Play](https://img.shields.io/badge/Download_It-Play_Store-green?logo=googleplay)](https://play.google.com/store/apps/details?id=com.ah.taplock) ![F-Droid Version](https://img.shields.io/f-droid/v/com.ah.taplock) - TapLock Logo A minimalist Android widget that lets you lock your screen with a double tap. Perfect for one-handed use and quick screen locking. ## Why TapLock? -While there are several screen-locking apps available on the Play Store, they all share a concerning characteristic: they're closed source while requiring significant system permissions. +While there are several screen-locking apps available on the Play Store, they all share a concerning characteristic: they're closed source while requiring significant system permissions. The Accessibility Service permission, which is necessary for screen locking functionality, is particularly powerful - it can: @@ -25,12 +24,14 @@ The Accessibility Service permission, which is necessary for screen locking func - Access sensitive information With closed-source applications, you're essentially trusting unknown code with these extensive capabilities. You have no way to verify: + - What data they're actually collecting - Where that data might be sent - What other hidden functionalities might exist - How securely they handle their permissions TapLock solves this trust problem through radical transparency: + - 100% open source - every line of code is visible and auditable - No internet permissions - physically cannot transmit data - Minimal, focused codebase - easy to review and verify @@ -46,6 +47,7 @@ You don't have to trust our privacy claims - you can verify them yourself in the - 🎯 Customizable double-tap timeout - 🔍 Transparent widget - doesn't interfere with your home screen aesthetics - 🛡️ Minimal permissions - only uses accessibility service for screen locking +- 🪄 Optional root lock mode on rooted devices - simulates a real power button press, so the screen turns off instantly without the lock-screen flicker - 📱 Resizable widget - 🎨 Material Design 3 UI - 🔒 Privacy focused - collects no personal data @@ -53,6 +55,7 @@ You don't have to trust our privacy claims - you can verify them yourself in the ## Getting Started ### Requirements + - Android 12 (API 31) or higher - 1MB of free space @@ -73,9 +76,19 @@ You don't have to trust our privacy claims - you can verify them yourself in the 2. Adjust the double-tap timeout in Settings (default: 300ms) 3. The widget is transparent by default and can be resized to your preference +#### Root lock mode (optional, rooted devices only) + +If TapLock detects a rooted device, it offers a choice of lock method: + +- **Accessibility** (default): uses Android's `GLOBAL_ACTION_LOCK_SCREEN`, which briefly shows the lock screen before the display turns off. +- **Root (power button)**: injects a power button key event through a root (`su`) shell, so the screen turns off instantly with no flicker - exactly like pressing the physical power button. + +When you select root mode, TapLock requests superuser access once from your root manager (e.g. Magisk). If root access is denied or the shell fails, TapLock automatically falls back to the accessibility lock. With root mode enabled, the widget and Quick Settings tile can lock the screen even without the accessibility service, though the tap-based lock triggers (status bar, lock screen, floating button) still require it. + ## Building from Source 1. Clone the repository: + ```bash git clone https://github.com/modelorona/TapLock.git ``` @@ -83,6 +96,7 @@ git clone https://github.com/modelorona/TapLock.git 2. Open the project in Android Studio Hedgehog or later 3. Build the project: + ```bash ./gradlew assembleDebug ``` @@ -90,9 +104,10 @@ git clone https://github.com/modelorona/TapLock.git ## Privacy TapLock is designed with privacy in mind: + - No internet connectivity required - No data collection -- Minimal permissions (only Accessibility Service) +- Minimal permissions (only Accessibility Service; superuser access is optional, opt-in, and used solely to simulate the power button) - Open source for transparency ## Contributing @@ -101,4 +116,4 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## License -This project is licensed under the Apache License 2.0 - see the LICENSE file for details \ No newline at end of file +This project is licensed under the Apache License 2.0 - see the LICENSE file for details diff --git a/fastlane/metadata/android/en-US/changelogs/20.txt b/fastlane/metadata/android/en-US/changelogs/20.txt new file mode 100644 index 0000000..a0824e6 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20.txt @@ -0,0 +1,3 @@ +* Optional root lock mode on rooted devices: simulates a real power button press, no lock-screen flicker, with automatic fallback to accessibility +* Prompt to exempt TapLock from battery optimization for more reliable locking +* Option to disable the widget tap ripple effect diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 3a09515..4789e7c 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -13,6 +13,7 @@ Additional features: * Adjustable double-tap timeout and lock delay * Optional vibration feedback with light, medium, and strong patterns * Per-app exclusions so taps are ignored in apps you choose +* Optional root lock mode on rooted devices: simulates a real power button press so the screen turns off instantly, with no lock-screen flicker * Available in English and Spanish TapLock uses Android's accessibility service to power off the screen. This permission is required for the app to function, and TapLock does not collect any personal information or track any other interaction you have with your device. The full source code is available for transparency. diff --git a/fastlane/metadata/android/es-ES/changelogs/20.txt b/fastlane/metadata/android/es-ES/changelogs/20.txt new file mode 100644 index 0000000..95289e0 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/20.txt @@ -0,0 +1,3 @@ +* Modo de bloqueo root opcional en dispositivos rooteados: simula una pulsación real del botón de encendido, sin parpadeo de la pantalla de bloqueo y con reserva automática a accesibilidad +* Aviso para eximir a TapLock de la optimización de batería y bloquear con más fiabilidad +* Opción para desactivar el efecto de onda al tocar el widget diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 6823bb2..bb16d18 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -13,6 +13,7 @@ Funciones adicionales: * Tiempo límite de doble toque y retardo de bloqueo ajustables * Vibración opcional con patrones ligero, medio y fuerte * Exclusiones por app para ignorar los toques en las apps que elijas +* Modo de bloqueo root opcional en dispositivos rooteados: simula una pulsación real del botón de encendido, la pantalla se apaga al instante y sin parpadeo * Disponible en inglés y español TapLock usa el servicio de accesibilidad de Android para apagar la pantalla. Este permiso es necesario para que la app funcione, y TapLock no recopila ninguna información personal ni registra ninguna otra interacción que tengas con el dispositivo. El código fuente completo está disponible para mayor transparencia. From d17e9d92ac8f7dc11870cf184f1b7f1c6a4866a0 Mon Sep 17 00:00:00 2001 From: Nello Carotenuto Date: Wed, 2 Sep 2026 00:49:31 +0200 Subject: [PATCH 6/6] Add root lock mode documentation and improve related UI elements - Updated README with instructions for root option visibility. - Enhanced root detection logic in RootLock.kt. - Added root note string to InfoSection and onboarding flow. - Updated accessibility settings intent for better user experience. - Translated new strings for root lock mode in both English and Spanish. --- README.md | 12 +++++ app/src/main/AndroidManifest.xml | 5 +- .../main/java/com/ah/taplock/MainActivity.kt | 50 +++++++++++++++++-- app/src/main/java/com/ah/taplock/RootLock.kt | 22 ++++++-- .../java/com/ah/taplock/SettingsComponents.kt | 10 +++- .../java/com/ah/taplock/TapLockTileService.kt | 2 +- app/src/main/java/com/ah/taplock/Utils.kt | 24 +++++++++ app/src/main/res/values-es/strings.xml | 7 +++ app/src/main/res/values/strings.xml | 9 ++++ 9 files changed, 128 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7a39a5c..76f9e8f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,18 @@ If TapLock detects a rooted device, it offers a choice of lock method: When you select root mode, TapLock requests superuser access once from your root manager (e.g. Magisk). If root access is denied or the shell fails, TapLock automatically falls back to the accessibility lock. With root mode enabled, the widget and Quick Settings tile can lock the screen even without the accessibility service, though the tap-based lock triggers (status bar, lock screen, floating button) still require it. +##### Root option not showing up? + +TapLock detects root by looking for the `su` binary. Managers like Magisk mount `su` into an app's namespace **only when the app's process starts** — so if TapLock was already running (even in the background) before root became visible to it, the root option cannot appear until the process is restarted. TapLock re-checks on every resume, but the namespace limitation means a restart is usually what actually fixes it. + +If you're rooted but don't see the lock method choice: + +1. **Force-close TapLock and reopen it**: go to system Settings → Apps → TapLock → Force stop (or long-press the app icon → App info → Force stop), then open the app again. A device reboot also works. +2. **Check your root manager's deny/hide list**: make sure TapLock is _not_ in Magisk's DenyList (or your manager's equivalent root-hiding feature), otherwise `su` stays invisible to it permanently. +3. **Grant superuser access before first launch** (if your manager supports it): managers such as KernelSU or APatch let you pre-grant root to an app from their own UI. Doing this before opening TapLock for the first time ensures root is detected immediately. Magisk only lists apps after their first `su` request, so with Magisk just use the force-close approach and approve the prompt when TapLock asks. + +When the superuser prompt appears, choose "Grant" (and "remember"/"forever" if offered) so subsequent locks don't re-prompt. + ## Building from Source 1. Clone the repository: diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c88bc6c..85cf264 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,8 @@ - + - + \ No newline at end of file diff --git a/app/src/main/java/com/ah/taplock/MainActivity.kt b/app/src/main/java/com/ah/taplock/MainActivity.kt index 8049164..28d2bdb 100644 --- a/app/src/main/java/com/ah/taplock/MainActivity.kt +++ b/app/src/main/java/com/ah/taplock/MainActivity.kt @@ -658,6 +658,18 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } } + val readmeRootUrl = stringResource(R.string.readme_root_url) + val rootNoteString = buildAnnotatedString { + append(stringResource(R.string.home_screen_root_note)) + append(" ") + withLink(LinkAnnotation.Url( + url = readmeRootUrl, + styles = TextLinkStyles(style = SpanStyle(textDecoration = TextDecoration.Underline)) + )) { + append(readmeRootUrl) + } + } + val excludedAppLabels = excludedPackages .map { packageName -> availableApps.firstOrNull { it.packageName == packageName }?.label @@ -734,6 +746,13 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val prefs = context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) lockCount = prefs.getInt(lockCountKey, 0) lockMethodRoot = prefs.getString(lockMethodKey, null) == RootLock.LOCK_METHOD_ROOT + // Root managers can expose su after first launch, so re-detect on every resume. + coroutineScope.launch { + val rooted = withContext(Dispatchers.IO) { RootLock.isDeviceRooted() } + isDeviceRooted = rooted + showRootModePrompt = + rooted && !prefs.getBoolean(rootModePromptShownKey, false) + } // Re-evaluate on every resume so the prompt fires right after the user comes // back from enabling the accessibility service. showBatteryPrompt = isAccessibilityEnabled && isBatteryOptimized && @@ -846,7 +865,8 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { .edit { putBoolean(hasSeenInfoKey, true) } } }, - disclaimerString = disclaimerString + disclaimerString = disclaimerString, + rootNoteString = rootNoteString ) if (isAdvancedProtectionEnabled) { @@ -2002,7 +2022,7 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { TextButton( onClick = { showDialog = false - context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + context.startActivity(accessibilitySettingsIntent(context)) } ) { Text(stringResource(R.string.agree)) @@ -2090,6 +2110,9 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { } if (showOnboarding) { + val onboardingWidgetManager = remember { AppWidgetManager.getInstance(context) } + val onboardingWidgetPinSupported = + remember { onboardingWidgetManager.isRequestPinAppWidgetSupported } val onboardingTitle = when (onboardingStep) { 0 -> stringResource(R.string.onboarding_welcome_title) 1 -> stringResource(R.string.onboarding_accessibility_title) @@ -2099,7 +2122,11 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { val onboardingBody = when (onboardingStep) { 0 -> stringResource(R.string.onboarding_welcome_body) 1 -> stringResource(R.string.onboarding_accessibility_body) - 2 -> stringResource(R.string.onboarding_widget_body) + 2 -> if (onboardingWidgetPinSupported) { + stringResource(R.string.onboarding_widget_body_pin) + } else { + stringResource(R.string.onboarding_widget_body) + } else -> stringResource(R.string.onboarding_done_body) } @@ -2111,7 +2138,17 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { TextButton( onClick = { when (onboardingStep) { - 1 -> context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + 1 -> context.startActivity(accessibilitySettingsIntent(context)) + 2 -> if (onboardingWidgetPinSupported) { + val requested = onboardingWidgetManager.requestPinAppWidget( + ComponentName(context, TapLockWidgetProvider::class.java), + null, + null + ) + if (!requested) { + TapLockFeedback.showWidgetPinUnsupported(context) + } + } 3 -> { showOnboarding = false context.getSharedPreferences(sharedPrefName, Context.MODE_PRIVATE) @@ -2124,6 +2161,11 @@ fun TapLockScreen(accessibilityEnabledOverride: Boolean? = null) { Text( when (onboardingStep) { 1 -> stringResource(R.string.onboarding_open_settings) + 2 -> if (onboardingWidgetPinSupported) { + stringResource(R.string.onboarding_add_widget) + } else { + stringResource(R.string.onboarding_next) + } 3 -> stringResource(R.string.onboarding_done) else -> stringResource(R.string.onboarding_next) } diff --git a/app/src/main/java/com/ah/taplock/RootLock.kt b/app/src/main/java/com/ah/taplock/RootLock.kt index 00fe9d4..0eb7223 100644 --- a/app/src/main/java/com/ah/taplock/RootLock.kt +++ b/app/src/main/java/com/ah/taplock/RootLock.kt @@ -28,7 +28,11 @@ object RootLock { private val mainHandler = Handler(Looper.getMainLooper()) - /** Cheap heuristic check for a rooted device: looks for a `su` binary on disk and in PATH. */ + /** + * Cheap heuristic check for a rooted device: looks for a `su` binary on disk and in PATH. + * Note: Magisk-style managers mount `su` into an app's namespace only at process start, so a + * process launched before root was available won't see it until it is restarted (see README). + */ fun isDeviceRooted(): Boolean { val commonPaths = listOf( "/system/bin/su", @@ -44,9 +48,19 @@ object RootLock { ) if (commonPaths.any { runCatching { File(it).exists() }.getOrDefault(false) }) return true val pathDirs = System.getenv("PATH")?.split(':').orEmpty() - return pathDirs.any { dir -> - dir.isNotEmpty() && runCatching { File(dir, "su").exists() }.getOrDefault(false) - } + if (pathDirs.any { dir -> + dir.isNotEmpty() && runCatching { File(dir, "su").exists() }.getOrDefault(false) + } + ) return true + // Some root setups hide the binary from direct path probes; a shell lookup can still + // resolve it without triggering a superuser prompt. + return runCatching { + val process = ProcessBuilder("which", "su").start() + val finished = process.waitFor(2, TimeUnit.SECONDS) + if (!finished) process.destroyForcibly() + finished && process.exitValue() == 0 && + process.inputStream.bufferedReader().readText().isNotBlank() + }.getOrDefault(false) } /** True when the user selected root as the lock method. */ diff --git a/app/src/main/java/com/ah/taplock/SettingsComponents.kt b/app/src/main/java/com/ah/taplock/SettingsComponents.kt index db9825d..2def67b 100644 --- a/app/src/main/java/com/ah/taplock/SettingsComponents.kt +++ b/app/src/main/java/com/ah/taplock/SettingsComponents.kt @@ -74,14 +74,16 @@ fun SettingsSectionCard( } /** - * Collapsible "about" card with app description, instructions, and [disclaimerString]. + * Collapsible "about" card with app description, instructions, [disclaimerString], and + * [rootNoteString] pointing at the README's root lock mode docs. * [expanded] state is hoisted so the screen can persist it across recompositions. */ @Composable fun InfoSection( expanded: Boolean, onToggle: () -> Unit, - disclaimerString: AnnotatedString + disclaimerString: AnnotatedString, + rootNoteString: AnnotatedString ) { val chevronRotation by animateFloatAsState( targetValue = if (expanded) 180f else 0f, @@ -127,6 +129,10 @@ fun InfoSection( stringResource(R.string.home_screen_instructions), style = MaterialTheme.typography.bodyLarge ) + Text( + rootNoteString, + style = MaterialTheme.typography.bodyLarge + ) Text( disclaimerString, style = MaterialTheme.typography.bodyLarge diff --git a/app/src/main/java/com/ah/taplock/TapLockTileService.kt b/app/src/main/java/com/ah/taplock/TapLockTileService.kt index 7570aea..5503d98 100644 --- a/app/src/main/java/com/ah/taplock/TapLockTileService.kt +++ b/app/src/main/java/com/ah/taplock/TapLockTileService.kt @@ -91,7 +91,7 @@ class TapLockTileService : TileService() { startService(accessibilityIntent) } else { TapLockFeedback.showAccessibilityRequired(this) - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + val intent = accessibilitySettingsIntent(this).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } if (android.os.Build.VERSION.SDK_INT >= 34) { diff --git a/app/src/main/java/com/ah/taplock/Utils.kt b/app/src/main/java/com/ah/taplock/Utils.kt index 8d2a522..b4c25f1 100644 --- a/app/src/main/java/com/ah/taplock/Utils.kt +++ b/app/src/main/java/com/ah/taplock/Utils.kt @@ -3,7 +3,10 @@ package com.ah.taplock import android.accessibilityservice.AccessibilityServiceInfo import android.content.ComponentName import android.content.Context +import android.content.Intent import android.os.Build +import android.os.Bundle +import android.provider.Settings import android.security.advancedprotection.AdvancedProtectionManager import android.view.accessibility.AccessibilityManager import androidx.annotation.RequiresApi @@ -66,3 +69,24 @@ fun isAccessibilityEnabled(context: Context): Boolean { ComponentName(resolveInfo.packageName, resolveInfo.name) == serviceComponent } } + +// Undocumented-but-stable Settings extras (used by AOSP Settings since Android 9) that make the +// accessibility list scroll to and flash-highlight a specific entry. +private const val EXTRA_FRAGMENT_ARG_KEY = ":settings:fragment_args_key" +private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + +/** + * Intent for the system accessibility settings that highlights (blinks) TapLock's entry so the + * user can find it immediately. Launchers without AOSP-style Settings simply ignore the extras. + */ +fun accessibilitySettingsIntent(context: Context): Intent { + val serviceName = + ComponentName(context, TapLockAccessibilityService::class.java).flattenToString() + return Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + putExtra(EXTRA_FRAGMENT_ARG_KEY, serviceName) + putExtra( + EXTRA_SHOW_FRAGMENT_ARGUMENTS, + Bundle().apply { putString(EXTRA_FRAGMENT_ARG_KEY, serviceName) } + ) + } +} diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a4eb2d4..f624b1e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -24,6 +24,10 @@ datos. Para mayor transparencia, el código fuente está disponible en el enlace siguiente: Para añadir el widget, ve a la pantalla de inicio, mantén pulsado un espacio vacío y busca el widget de TapLock. + En dispositivos rooteados se ofrece un modo de bloqueo + por root opcional. El acceso root solo es visible para TapLock al iniciarse su proceso, + así que si la opción no aparece, fuerza el cierre de la app y vuelve a abrirla. Consulta la + sección \"Root lock mode\" del README para más detalles: Permisos requeridos Servicio de accesibilidad Acceso rápido @@ -117,6 +121,9 @@ Añadir el widget Ve a la pantalla de inicio, mantén pulsado un espacio vacío y añade el widget de TapLock. Tócalo dos veces para bloquear. + Toca Añadir widget para colocar el widget de TapLock + en tu pantalla de inicio. Tócalo dos veces para bloquear. + Añadir widget Todo listo TapLock está listo para usarse. Puedes personalizar los ajustes en cualquier momento desde esta pantalla. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28fced2..6407073 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,7 +28,13 @@ access your data. For transparency, the source code is available via the link below: To add the widget, please go to your home screen, long-press on empty space, and find the TapLock widget. + On rooted devices, an optional root lock mode is offered. + Root access is only visible to TapLock when its process starts, so if the option doesn\'t + appear, force-close and reopen the app. See the \"Root lock mode\" section in the README + for details: https://github.com/modelorona/TapLock + + https://github.com/modelorona/TapLock#root-lock-mode-optional-rooted-devices-only Required Permissions Accessibility Service Quick Access @@ -151,6 +157,9 @@ Add the Widget Go to your home screen, long-press on empty space, and add the TapLock widget. Double-tap it to lock! + Tap Add Widget to place the TapLock widget on your + home screen. Double-tap it to lock! + Add Widget You\'re All Set! TapLock is ready to use. You can customise settings any time from this screen.