Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,9 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane
<option value="always">
{t('settings.permissions.androidOverlayTrigger.always')}
</option>
<option value="never">
{t('settings.permissions.androidOverlayTrigger.never')}
</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: hintAlign }}>
{t(
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/android/frontend/lib/androidTypes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** Android-specific preference and status types (mirrors Rust IPC payloads). */

export type AndroidInsertStrategy = 'accessibility' | 'clipboard';
export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always';
export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always' | 'never';
export type AndroidOverlayActivationMode = 'tap' | 'long_press';
export type AndroidOverlayLeftSwipeAction = 'translation' | 'style_pack';
export type AndroidOverlayCancelSwipeDirection = 'up' | 'down';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class OpenLessAccessibilityService : AccessibilityService() {

private fun refreshVocabularyCard() {
val request = ++vocabularyCardRequest
if (!OpenLessAndroidPreferences.overlayDisplayAllowed(this)) {
hideVocabularyCard()
return
}
requestVocabulary("pending") { ok, response ->
if (request == vocabularyCardRequest) showVocabularyCard(if (ok) response?.getString("json") ?: "[]" else "[]")
}
Expand Down Expand Up @@ -172,6 +176,7 @@ class OpenLessAccessibilityService : AccessibilityService() {

private fun showVocabularyCard(json: String) {
hideVocabularyCard()
if (!OpenLessAndroidPreferences.overlayDisplayAllowed(this)) return
val entries = try { org.json.JSONArray(json) } catch (_: Exception) { return }
val now = System.currentTimeMillis()
val pending = (0 until entries.length()).map { entries.getJSONObject(it) }.filter { it.optLong("expiresAtMs") > now }
Expand Down Expand Up @@ -752,7 +757,7 @@ class OpenLessAccessibilityService : AccessibilityService() {
}

companion object {
private fun sendVocabularyCommand(operation: String, generation: Long = 0L) {
internal fun sendVocabularyCommand(operation: String, generation: Long = 0L) {
val context = OpenLessAppContext.context ?: return
val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply {
action = OpenLessAccessibilityCommandReceiver.ACTION_VOCABULARY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ object OpenLessAndroidPreferences {
private const val DEFAULT_OVERLAY_SIZE_DP = 72
private const val MIN_OVERLAY_SIZE_DP = 48
private const val MAX_OVERLAY_SIZE_DP = 120
private val VALID_OVERLAY_TRIGGERS = setOf("background", "always")
private val VALID_OVERLAY_TRIGGERS = setOf("background", "always", "never")
private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press")
private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack")
private val VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS = setOf("up", "down")
Expand All @@ -35,6 +35,13 @@ object OpenLessAndroidPreferences {
return value.takeIf { it in VALID_OVERLAY_TRIGGERS }
}

fun overlayDisplayAllowed(context: Context): Boolean {
return shouldDisplayOverlay(overlayTriggerMode(context))
}

/** The `never` mode blocks every automatic overlay creation path. */
internal fun shouldDisplayOverlay(trigger: String?): Boolean = trigger != "never"

/** True when preferences still store legacy `"keyboard"` (before migration). */
fun isKeyboardOverlayTrigger(context: Context): Boolean {
return readPreferenceString(context, KEY_OVERLAY_TRIGGER) == "keyboard"
Expand Down
42 changes: 37 additions & 5 deletions openless-all/app/android/kotlin/OpenLessOverlayService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
when (intent?.action) {
null, ACTION_SHOW -> showOverlay()
ACTION_START_RECORDING -> {
showOverlay()
if (!showOverlay()) {
stopSelf(startId)
return START_NOT_STICKY
}
if (!tryPromoteRecordingForeground()) {
abortRecordingStart(startId)
return START_NOT_STICKY
Expand All @@ -119,7 +122,13 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
stopSelf(startId)
}
ACTION_REPLACE_OVERLAY -> replaceOverlay()
ACTION_TOGGLE_EXPAND -> handleIconClick()
ACTION_TOGGLE_EXPAND -> {
if (overlayDisplayAllowed()) {
handleIconClick()
} else {
hideOverlay()
}
}
ACTION_KEYBOARD_CHANGED -> handleKeyboardChanged(intent)
ACTION_REFRESH_LAYOUT -> refreshOverlayLayout()
}
Expand Down Expand Up @@ -191,15 +200,20 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
}
}

private fun showOverlay() = withOverlayLock {
private fun showOverlay(): Boolean = withOverlayLock {
if (!overlayDisplayAllowed()) {
clearAllOverlayRoots()
Log.i(TAG, "overlay show skipped by preference")
return@withOverlayLock false
}
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
reconcileOverlayRoots()
overlayRoots.lastOrNull()?.let { existing ->
val params = existing.layoutParams as? WindowManager.LayoutParams
if (params != null) {
refreshExistingOverlayLayout(existing, params)
Log.i(TAG, "overlay already shown roots=${overlayRoots.size}")
return@withOverlayLock
return@withOverlayLock true
}
removeOverlayRoot(existing)
synchronized(overlayRoots) {
Expand All @@ -220,6 +234,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
if (removed > 0) {
Log.i(TAG, "overlay replace cleared removed=$removed")
}
if (!overlayDisplayAllowed()) {
Log.i(TAG, "overlay replace skipped by preference")
return@withOverlayLock
}
if (!canDrawOverlays()) {
Log.i(TAG, "overlay replace skipped no overlay permission")
return@withOverlayLock
Expand All @@ -235,6 +253,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList

private fun hideOverlay() = withOverlayLock {
val removed = clearAllOverlayRoots()
if (!overlayDisplayAllowed()) {
OpenLessAccessibilityService.sendVocabularyCommand("refresh")
}
if (removed > 0) {
Log.i(TAG, "overlay hidden removed=$removed")
}
Expand Down Expand Up @@ -321,6 +342,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
return OpenLessPermissionBridge.canDrawOverlaysSafely(this)
}

private fun overlayDisplayAllowed(): Boolean {
return OpenLessAndroidPreferences.overlayDisplayAllowed(this)
}

private fun refreshExistingOverlayLayout(
root: FrameLayout,
params: WindowManager.LayoutParams,
Expand All @@ -338,6 +363,11 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
private fun refreshOverlayLayout() = withOverlayLock {
windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager
reconcileOverlayRoots()
if (!overlayDisplayAllowed()) {
clearAllOverlayRoots()
Log.i(TAG, "overlay refresh skipped by preference")
return@withOverlayLock
}
val root = overlayRoots.lastOrNull() ?: rootView
if (root == null || !root.isAttachedToWindow) {
Log.i(TAG, "overlay refresh skipped roots=0")
Expand Down Expand Up @@ -752,7 +782,9 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
}

private fun startRecordingFromOverlay(translation: Boolean = false) {
showOverlay()
if (!showOverlay()) {
return
}
if (tryPromoteRecordingForeground()) {
beginDictationFromOverlay(translation)
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ class OpenLessOverlayStateTest {
assertFalse(shouldIgnoreOverlayRecordingFrame(true, "voice edit capture failed"))
assertFalse(shouldIgnoreOverlayRecordingFrame(false, null))
}

@Test
fun neverTriggerBlocksOverlayDisplayButOtherModesRemainAvailable() {
assertFalse(OpenLessAndroidPreferences.shouldDisplayOverlay("never"))
assertTrue(OpenLessAndroidPreferences.shouldDisplayOverlay("background"))
assertTrue(OpenLessAndroidPreferences.shouldDisplayOverlay("always"))
assertTrue(OpenLessAndroidPreferences.shouldDisplayOverlay(null))
}
}
51 changes: 51 additions & 0 deletions openless-all/app/crates/openless-core/src/android_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub enum AndroidOverlayTrigger {
Background,
Keyboard,
Always,
Never,
}

impl AndroidOverlayTrigger {
Expand Down Expand Up @@ -399,6 +400,56 @@ mod android_overlay_tests {
},
);
}

#[test]
fn never_trigger_is_preserved_by_normalization_and_json() {
assert_eq!(
AndroidOverlayTrigger::Never.normalized(),
AndroidOverlayTrigger::Never,
);
assert_eq!(
serde_json::to_string(&AndroidOverlayTrigger::Never).unwrap(),
"\"never\"",
);
assert_eq!(
serde_json::from_str::<AndroidOverlayTrigger>("\"never\"").unwrap(),
AndroidOverlayTrigger::Never,
);
}

#[test]
fn default_overlay_trigger_remains_background() {
assert_eq!(
default_android_overlay_trigger(),
AndroidOverlayTrigger::Background,
);
}

#[test]
fn every_distinct_trigger_transition_is_classified() {
let triggers = [
AndroidOverlayTrigger::Background,
AndroidOverlayTrigger::Always,
AndroidOverlayTrigger::Never,
];
for from in triggers {
for to in triggers {
let expected = if from == to {
AndroidOverlaySettingsAction::None
} else {
AndroidOverlaySettingsAction::Transition { from, to }
};
assert_eq!(
classify_android_overlay_settings_change(
&overlay_prefs(from, 72, AndroidOverlayActivationMode::Tap),
&overlay_prefs(to, 72, AndroidOverlayActivationMode::Tap),
),
expected,
"unexpected transition from {from:?} to {to:?}",
);
}
}
}
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
Expand Down
35 changes: 35 additions & 0 deletions openless-all/app/scripts/android-overlay-never-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const source = (name) =>
readFileSync(new URL(`../android/kotlin/${name}.kt`, import.meta.url), 'utf8');
const accessibility = source('OpenLessAccessibilityService');
const overlay = source('OpenLessOverlayService');

assert.match(
accessibility,
/private fun refreshVocabularyCard\(\)\s*\{\s*val request = \+\+vocabularyCardRequest\s*if \(!OpenLessAndroidPreferences\.overlayDisplayAllowed\(this\)\)\s*\{\s*hideVocabularyCard\(\)\s*return\s*\}\s*requestVocabulary\("pending"\)/,
'Disabling overlays must invalidate old replies and hide the card without waiting for pending-suggestion IPC',
);
assert.match(
accessibility,
/private fun showVocabularyCard\(json: String\)\s*\{\s*hideVocabularyCard\(\)\s*if \(!OpenLessAndroidPreferences\.overlayDisplayAllowed\(this\)\) return/,
'Every vocabulary card render, including delayed IPC replies, must hide the old card and honor never before creating a new one',
);
assert.match(
overlay,
/private fun hideOverlay\(\)[\s\S]*?clearAllOverlayRoots\(\)[\s\S]*?if \(!overlayDisplayAllowed\(\)\)\s*\{\s*OpenLessAccessibilityService\.sendVocabularyCommand\("refresh"\)/,
'Hiding overlays in never mode must also refresh cards in the accessibility process',
);
assert.match(
accessibility,
/internal fun sendVocabularyCommand\([\s\S]*?ACTION_VOCABULARY[\s\S]*?context\.sendBroadcast\(intent\)/,
'The overlay service must reuse the cross-process vocabulary command',
);
assert.match(
accessibility,
/"refresh" -> service\.refreshVocabularyCard\(\)/,
'The accessibility process must refresh the card after a preference change',
);

console.log('android overlay never contract tests passed');
22 changes: 11 additions & 11 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1062,8 +1062,11 @@ impl Coordinator {
AndroidOverlayTrigger::Background => "background",
AndroidOverlayTrigger::Keyboard => "keyboard",
AndroidOverlayTrigger::Always => "always",
AndroidOverlayTrigger::Never => "never",
}
}
let from = from.normalized();
let to = to.normalized();
if from == to {
return;
}
Expand All @@ -1072,20 +1075,15 @@ impl Coordinator {
overlay_trigger_log_name(from),
overlay_trigger_log_name(to),
);
match (from, to) {
(
AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard,
AndroidOverlayTrigger::Always,
) => {
match to {
AndroidOverlayTrigger::Always => {
let _ = crate::android::replace_android_overlay();
}
(
AndroidOverlayTrigger::Always,
AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard,
) => {
AndroidOverlayTrigger::Background
| AndroidOverlayTrigger::Keyboard
| AndroidOverlayTrigger::Never => {
let _ = crate::android::hide_android_overlay();
}
_ => {}
}
}
let _ = (from, to);
Expand All @@ -1099,7 +1097,9 @@ impl Coordinator {
AndroidOverlayTrigger::Always => {
let _ = crate::android::replace_android_overlay();
}
AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard => {
AndroidOverlayTrigger::Background
| AndroidOverlayTrigger::Keyboard
| AndroidOverlayTrigger::Never => {
let _ = crate::android::hide_android_overlay();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ impl openless_core::config::RestoreRuntimeEffects for RestoreHost {
crate::android::replace_android_overlay()
}
crate::types::AndroidOverlayTrigger::Background
| crate::types::AndroidOverlayTrigger::Keyboard => {
| crate::types::AndroidOverlayTrigger::Keyboard
| crate::types::AndroidOverlayTrigger::Never => {
crate::android::hide_android_overlay()
}
}
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2150,13 +2150,15 @@ export const de: typeof zhCN = {
background: 'Wenn die App im Hintergrund ist',
keyboard: 'Wenn die Tastatur erscheint',
always: 'Immer sichtbar',
never: 'Nie anzeigen',
},
androidOverlayTriggerHint: {
background:
'Einfach und energiesparend. Kein schwebendes Fenster beim Tippen in anderen Apps.',
keyboard:
'Dieser Modus wird nicht weitergeführt. Bestehende Einstellungen werden auf „Im Hintergrund“ zurückgesetzt.',
always: 'Jederzeit verfügbar, aber dauerhaft auf dem Bildschirm.',
never: 'Wird nie automatisch angezeigt; die Tastatur bleibt nutzbar.',
},
androidOverlayTriggerDisabled: {
keyboard:
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2098,11 +2098,13 @@ export const en: typeof zhCN = {
background: 'When app is backgrounded',
keyboard: 'When keyboard appears',
always: 'Always visible',
never: 'Never show',
},
androidOverlayTriggerHint: {
background: 'Simple and battery-friendly; no overlay while typing in other apps.',
keyboard: 'This mode is shelved. Existing settings are moved back to background.',
always: 'Always available, but permanently on screen.',
never: 'Never appears automatically; the keyboard remains available.',
},
androidOverlayTriggerDisabled: {
keyboard:
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2134,13 +2134,15 @@ export const es: typeof zhCN = {
background: 'Con la aplicación en segundo plano',
keyboard: 'Cuando aparece el teclado',
always: 'Siempre visible',
never: 'Nunca mostrar',
},
androidOverlayTriggerHint: {
background:
'Sencillo y de bajo consumo; no muestra la ventana mientras escribes en otras aplicaciones.',
keyboard:
'Este modo se ha retirado. Los ajustes existentes vuelven al modo de segundo plano.',
always: 'Siempre disponible, pero permanece en pantalla.',
never: 'Nunca aparece automáticamente; el teclado sigue disponible.',
},
androidOverlayTriggerDisabled: {
keyboard:
Expand Down
Loading
Loading