Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
23 changes: 23 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,29 @@
</intent-filter>
</receiver>

<!--Device admin holding only the force lock policy, used by the command_screen_off
notification command to turn the display off (see ScreenOffHelper).-->
<receiver
android:name=".util.ScreenOffAdminReceiver"
android:description="@string/screen_off_admin_description"
android:exported="true"
android:label="@string/screen_off_admin_name"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/screen_off_device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>

<!--Transparent activity that opens the device admin activation screen for the
command_screen_off notification command (see ScreenOffAdminRequestActivity).-->
<activity
android:name=".util.ScreenOffAdminRequestActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />

<activity android:name=".launch.link.LinkActivity"
android:exported="true"
android:launchMode="singleTask">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ import io.homeassistant.companion.android.settings.assist.AssistConfigManager
import io.homeassistant.companion.android.settings.assist.DefaultAssistantManager
import io.homeassistant.companion.android.util.FlashlightHelper
import io.homeassistant.companion.android.util.PermissionRequestMediator
import io.homeassistant.companion.android.util.ScreenOffAdminRequestActivity
import io.homeassistant.companion.android.util.ScreenOffHelper
import io.homeassistant.companion.android.util.UrlUtil
import io.homeassistant.companion.android.util.sensitive
import io.homeassistant.companion.android.vehicle.HaCarAppService
Expand Down Expand Up @@ -131,6 +133,7 @@ class MessagingManager @Inject constructor(
private val settingsDao: SettingsDao,
private val textToSpeechClient: TextToSpeechClient,
private val flashlightHelper: FlashlightHelper,
private val screenOffHelper: ScreenOffHelper,
Comment thread
markfrancisonly marked this conversation as resolved.
private val permissionRequestMediator: PermissionRequestMediator,
private val assistConfigManager: AssistConfigManager,
private val defaultAssistantManager: DefaultAssistantManager,
Expand Down Expand Up @@ -186,6 +189,7 @@ class MessagingManager @Inject constructor(
const val COMMAND_VOLUME_LEVEL = "command_volume_level"
const val COMMAND_BLUETOOTH = "command_bluetooth"
const val COMMAND_SCREEN_ON = "command_screen_on"
const val COMMAND_SCREEN_OFF = "command_screen_off"
const val COMMAND_MEDIA = "command_media"
const val COMMAND_HIGH_ACCURACY_MODE = "command_high_accuracy_mode"
const val COMMAND_ACTIVITY = "command_activity"
Expand Down Expand Up @@ -247,6 +251,7 @@ class MessagingManager @Inject constructor(
COMMAND_ACTIVITY,
COMMAND_WEBVIEW,
COMMAND_SCREEN_ON,
COMMAND_SCREEN_OFF,
COMMAND_MEDIA,
DeviceCommandData.COMMAND_UPDATE_SENSORS,
COMMAND_LAUNCH_APP,
Expand Down Expand Up @@ -543,7 +548,7 @@ class MessagingManager @Inject constructor(
handleDeviceCommands(jsonData)
}

COMMAND_SCREEN_ON -> {
COMMAND_SCREEN_ON, COMMAND_SCREEN_OFF -> {
handleDeviceCommands(jsonData)
}

Expand Down Expand Up @@ -838,9 +843,20 @@ class MessagingManager @Inject constructor(
"HomeAssistant::NotificationScreenOnWakeLock",
)
wakeLock?.acquire(1 * 30 * 1000L) // 30 seconds
// Released while the wake lock is held so the device cannot suspend meanwhile
screenOffHelper.turnScreenOn()
wakeLock?.release()
}

COMMAND_SCREEN_OFF -> {
if (!screenOffHelper.canTurnScreenOff()) {
notifyMissingPermission(type = message, serverId = serverId)
} else if (!screenOffHelper.turnScreenOff()) {
Timber.d("Unable to turn the screen off, posting notification to device")
sendNotification(data)
}
}

COMMAND_MEDIA -> {
if (!NotificationManagerCompat.getEnabledListenerPackages(context)
.contains(context.packageName)
Expand Down Expand Up @@ -1801,6 +1817,13 @@ class MessagingManager @Inject constructor(
context.startActivity(intent)
}

private fun requestDeviceAdminPermission() {
// The activation screen only opens from an activity, see ScreenOffAdminRequestActivity
val intent = Intent(context, ScreenOffAdminRequestActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
}

private fun requestNotificationPermission() {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
Expand Down Expand Up @@ -2130,6 +2153,8 @@ class MessagingManager @Inject constructor(
when (type) {
COMMAND_WEBVIEW, COMMAND_ACTIVITY, COMMAND_LAUNCH_APP -> requestSystemAlertPermission()

COMMAND_SCREEN_OFF -> requestDeviceAdminPermission()

COMMAND_RINGER_MODE, COMMAND_DND, COMMAND_VOLUME_LEVEL -> requestDNDPermission()

COMMAND_MEDIA -> requestNotificationPermission()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.homeassistant.companion.android.util

import android.app.admin.DeviceAdminReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

/** Device admin holding the force lock policy so [ScreenOffHelper] can cut the display power. */
@AndroidEntryPoint
class ScreenOffAdminReceiver : DeviceAdminReceiver() {

@Inject
lateinit var screenOffHelper: ScreenOffHelper

// Without this the wake lock held while the screen was off would never be released
override fun onDisabled(context: Context, intent: Intent) {
screenOffHelper.turnScreenOn()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package io.homeassistant.companion.android.util

import android.app.admin.DevicePolicyManager
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import io.homeassistant.companion.android.common.R as commonR
import timber.log.Timber

/**
* Opens the system screen to activate [ScreenOffAdminReceiver] as device admin. Routed through
* this transparent activity since some manufacturers, like Samsung, refuse to open that screen
* from a new task, which is the only way the notification handling can start an activity.
*/
class ScreenOffAdminRequestActivity : ComponentActivity() {

private val requestAdmin =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { finish() }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
// A recreated instance cannot count on a pending result to finish it, so leave
// instead of lingering transparently over the task
finish()
return
}

Comment thread
markfrancisonly marked this conversation as resolved.
val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN)
.putExtra(
DevicePolicyManager.EXTRA_DEVICE_ADMIN,
ComponentName(this, ScreenOffAdminReceiver::class.java),
)
.putExtra(
DevicePolicyManager.EXTRA_ADD_EXPLANATION,
getString(commonR.string.screen_off_admin_description),
)
try {
requestAdmin.launch(intent)
} catch (e: ActivityNotFoundException) {
// Some devices, like Android Automotive, have no device admin settings
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
Timber.w(e, "Unable to open the device admin activation screen")
finish()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package io.homeassistant.companion.android.util

import android.annotation.SuppressLint
import android.app.KeyguardManager
import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.os.PowerManager
import androidx.annotation.VisibleForTesting
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import timber.log.Timber

private const val SCREEN_OFF_WAKE_LOCK_TAG = "HomeAssistant::NotificationScreenOffWakeLock"

/**
* Cuts the display power with [DevicePolicyManager.lockNow] while a partial wake lock keeps the
* device awake. Requires [ScreenOffAdminReceiver] active as device admin and no secure keyguard.
* All functions must be called from the main thread, the wake lock state is not synchronized.
*/
@Singleton
class ScreenOffHelper @Inject constructor(@ApplicationContext private val context: Context) {

private val devicePolicyManager by lazy { context.getSystemService<DevicePolicyManager>() }
private val keyguardManager by lazy { context.getSystemService<KeyguardManager>() }
private val powerManager by lazy { context.getSystemService<PowerManager>() }
private val adminComponent by lazy { ComponentName(context, ScreenOffAdminReceiver::class.java) }

private var screenOffWakeLock: PowerManager.WakeLock? = null

/** Whether the screen is currently turned off by [turnScreenOff]. */
@VisibleForTesting
internal val isScreenOff: Boolean
get() = screenOffWakeLock != null

/** Whether [ScreenOffAdminReceiver] is active as device admin so [turnScreenOff] can be used. */
fun canTurnScreenOff(): Boolean = devicePolicyManager?.isAdminActive(adminComponent) == true

/**
* @return `true` if the screen was turned off, `false` when the device admin is not active or
* a secure keyguard is set
*/
// The wake lock is held until the screen is turned back on, no timeout would be safe
@SuppressLint("WakelockTimeout")
fun turnScreenOff(): Boolean {
val devicePolicyManager = devicePolicyManager ?: return false
if (keyguardManager?.isDeviceSecure != false) {
Timber.w("Not turning the screen off, the secure keyguard would lock the device")
return false
}
return try {
if (screenOffWakeLock == null) {
// Keep the CPU and network awake so the device stays reachable by the server
screenOffWakeLock = powerManager
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, SCREEN_OFF_WAKE_LOCK_TAG)
?.apply { acquire() }
}
devicePolicyManager.lockNow()
true
} catch (e: SecurityException) {
Timber.e(e, "Device admin is not active, cannot turn the screen off")
turnScreenOn()
false
}
}

/** @return `true` if the screen was turned off before, `false` if there was nothing to do */
fun turnScreenOn(): Boolean {
val wakeLock = screenOffWakeLock ?: return false
screenOffWakeLock = null
if (wakeLock.isHeld) {
wakeLock.release()
}
return true
}
}
2 changes: 2 additions & 0 deletions app/src/main/res/xml/changelog_master.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
tools:ignore="MissingDefaultResource">
<release version="2026.7.2 - Main" versioncode="3">
<change>Android 17: added assistant volume level sensor + notification command for control</change>
<change>New command_screen_off notification command turns the screen off while the device stays awake and connected (requires activating the device admin); command_screen_on turns it back on</change>
<change>Bug fixes and dependency updates</change>
</release>
<release version="2026.7.2 - Wear" versioncode="2">
Expand All @@ -11,6 +12,7 @@
</release>
<release version="2026.7.2 - Automotive" versioncode="1">
<change>Android 17: added assistant volume level sensor + notification command for control</change>
<change>New command_screen_off notification command turns the screen off while the device stays awake and connected (requires activating the device admin); command_screen_on turns it back on</change>
<change>Bug fixes and dependency updates</change>
</release>
</changelog>
6 changes: 6 additions & 0 deletions app/src/main/res/xml/screen_off_device_admin.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies>
<force-lock />
</uses-policies>
</device-admin>
Loading