-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Add command_screen_off to turn the display off while the device stays awake #7162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
9fc056f
5d6fe8e
327010a
7d9ee3f
67811d2
be39869
cac0678
81e571d
4c8bb53
a8a3644
bb67524
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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,44 @@ | ||
| 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) return | ||
|
|
||
|
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 | ||
|
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 | ||
| } | ||
| } |
| 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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| package io.homeassistant.companion.android.notifications | ||
|
|
||
| import android.app.ActivityManager | ||
| import android.app.Application | ||
| import android.os.Looper | ||
| import android.os.Process | ||
| import androidx.test.core.app.ApplicationProvider | ||
| import dagger.hilt.android.testing.HiltTestApplication | ||
| import io.homeassistant.companion.android.common.data.integration.IntegrationRepository | ||
| import io.homeassistant.companion.android.common.data.servers.ServerManager | ||
| import io.homeassistant.companion.android.common.notifications.NotificationData | ||
| import io.homeassistant.companion.android.database.server.Server | ||
| import io.homeassistant.companion.android.util.ScreenOffAdminRequestActivity | ||
| import io.homeassistant.companion.android.util.ScreenOffHelper | ||
| import io.mockk.coEvery | ||
| import io.mockk.every | ||
| import io.mockk.mockk | ||
| import io.mockk.verify | ||
| import org.junit.Assert.assertEquals | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use Jupiter API
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Kept on Robolectric/JUnit 4: the test relies on idling the main looper, the started-activity capture, and the foreground-process shadow, which have no mock-context equivalent. The two test classes that don't need Robolectric (helper and receiver) are plain Jupiter in be39869. |
||
| import org.junit.Before | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
| import org.robolectric.RobolectricTestRunner | ||
| import org.robolectric.Shadows.shadowOf | ||
| import org.robolectric.annotation.Config | ||
|
|
||
| /** | ||
| * Covers the routing of the screen on and screen off commands only, [MessagingManager] has no | ||
| * further coverage yet. | ||
| */ | ||
| @RunWith(RobolectricTestRunner::class) | ||
| @Config(application = HiltTestApplication::class) | ||
| class MessagingManagerScreenCommandsTest { | ||
|
|
||
| private lateinit var screenOffHelper: ScreenOffHelper | ||
| private lateinit var messagingManager: MessagingManager | ||
|
|
||
| @Before | ||
| fun setUp() { | ||
| val serverManager = mockk<ServerManager>(relaxed = true) | ||
| val integrationRepository = mockk<IntegrationRepository>(relaxed = true) | ||
| coEvery { serverManager.getServer(any<Int>()) } returns mockk<Server>(relaxed = true) | ||
| coEvery { serverManager.integrationRepository(any()) } returns integrationRepository | ||
| coEvery { integrationRepository.isTrusted() } returns true | ||
| screenOffHelper = mockk(relaxed = true) | ||
|
|
||
| messagingManager = MessagingManager( | ||
| context = ApplicationProvider.getApplicationContext<Application>(), | ||
| okHttpClientProvider = mockk(relaxed = true), | ||
| serverManager = serverManager, | ||
| prefsRepository = mockk(relaxed = true), | ||
| notificationDao = mockk(relaxed = true), | ||
| sensorRepository = mockk(relaxed = true), | ||
| settingsDao = mockk(relaxed = true), | ||
| textToSpeechClient = mockk(relaxed = true), | ||
| flashlightHelper = mockk(relaxed = true), | ||
| screenOffHelper = screenOffHelper, | ||
| permissionRequestMediator = mockk(relaxed = true), | ||
| assistConfigManager = mockk(relaxed = true), | ||
| defaultAssistantManager = mockk(relaxed = true), | ||
| bluetoothSensorManager = mockk(relaxed = true), | ||
| ) | ||
| } | ||
|
|
||
| private fun handleMessage(message: String) { | ||
| messagingManager.handleMessage(mapOf(NotificationData.MESSAGE to message), "FCM") | ||
| // handleMessage launches on the main dispatcher, run the enqueued work before verifying | ||
| shadowOf(Looper.getMainLooper()).idle() | ||
| } | ||
|
|
||
| @Test | ||
| fun `Given screen off is possible when receiving screen off command then the screen is turned off`() { | ||
| every { screenOffHelper.canTurnScreenOff() } returns true | ||
| every { screenOffHelper.turnScreenOff() } returns true | ||
|
|
||
| handleMessage(MessagingManager.COMMAND_SCREEN_OFF) | ||
|
|
||
| verify(exactly = 1) { screenOffHelper.turnScreenOff() } | ||
| } | ||
|
|
||
| @Test | ||
| fun `Given screen off is not possible when receiving screen off command then the screen is not turned off`() { | ||
| every { screenOffHelper.canTurnScreenOff() } returns false | ||
|
|
||
| handleMessage(MessagingManager.COMMAND_SCREEN_OFF) | ||
|
|
||
| verify(exactly = 0) { screenOffHelper.turnScreenOff() } | ||
| } | ||
|
|
||
| @Test | ||
| fun `Given screen was turned off when receiving screen on command then the screen is turned back on`() { | ||
| handleMessage(MessagingManager.COMMAND_SCREEN_ON) | ||
|
|
||
| verify(exactly = 1) { screenOffHelper.turnScreenOn() } | ||
| } | ||
|
|
||
| @Test | ||
| fun `Given the app in the foreground when screen off is not possible then the device admin activation is opened`() { | ||
| every { screenOffHelper.canTurnScreenOff() } returns false | ||
| val application = ApplicationProvider.getApplicationContext<Application>() | ||
| val processInfo = ActivityManager.RunningAppProcessInfo( | ||
| application.applicationInfo.processName, | ||
| Process.myPid(), | ||
| null, | ||
| ).apply { importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND } | ||
| shadowOf(application.getSystemService(ActivityManager::class.java)).setProcesses(listOf(processInfo)) | ||
|
|
||
| handleMessage(MessagingManager.COMMAND_SCREEN_OFF) | ||
|
|
||
| assertEquals( | ||
| ScreenOffAdminRequestActivity::class.java.name, | ||
| shadowOf(application).nextStartedActivity?.component?.className, | ||
| ) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.