Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 26e2ce35 authored by Lucas Silva's avatar Lucas Silva
Browse files

Handle non-activity trampolines from widgets

If a widget triggers an activity through a broadcast or service, we
currently do not trigger auth. This change adds detection logic to
detect when an activity is started within 1 second of a widget
interaction, and prompts for auth.

Bug: 350468769
Test: atest WidgetTrampolineInteractorTest
Test: atest WidgetInteractionHandlerTest
Flag: com.android.systemui.communal_widget_trampoline_fix
Change-Id: Ib8436a17cf6d413fb59a8d6cc6081b2d9660a3d1
parent 9fc77b11
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -997,6 +997,16 @@ flag {
  }
}

flag {
  name: "communal_widget_trampoline_fix"
  namespace: "systemui"
  description: "fixes activity starts caused by non-activity trampolines from widgets."
  bug: "350468769"
  metadata {
    purpose: PURPOSE_BUGFIX
  }
}

flag {
  name: "app_clips_backlinks"
  namespace: "systemui"
+220 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.communal.domain.interactor

import android.app.ActivityManager.RunningTaskInfo
import android.app.usage.UsageEvents
import android.content.pm.UserInfo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.usagestats.data.repository.fakeUsageStatsRepository
import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.TransitionState
import com.android.systemui.keyguard.shared.model.TransitionStep
import com.android.systemui.kosmos.testScope
import com.android.systemui.plugins.activityStarter
import com.android.systemui.settings.fakeUserTracker
import com.android.systemui.shared.system.taskStackChangeListeners
import com.android.systemui.testKosmos
import com.android.systemui.util.time.fakeSystemClock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class WidgetTrampolineInteractorTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope

    private val activityStarter = kosmos.activityStarter
    private val usageStatsRepository = kosmos.fakeUsageStatsRepository
    private val taskStackChangeListeners = kosmos.taskStackChangeListeners
    private val keyguardTransitionRepository = kosmos.fakeKeyguardTransitionRepository
    private val userTracker = kosmos.fakeUserTracker
    private val systemClock = kosmos.fakeSystemClock

    private val underTest = kosmos.widgetTrampolineInteractor

    @Before
    fun setUp() {
        userTracker.set(listOf(MAIN_USER), 0)
        systemClock.setCurrentTimeMillis(testScope.currentTime)
    }

    @Test
    fun testNewTaskStartsWhileOnHub_triggersUnlock() =
        testScope.runTest {
            transition(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GLANCEABLE_HUB)
            backgroundScope.launch { underTest.waitForActivityStartAndDismissKeyguard() }
            runCurrent()

            verify(activityStarter, never()).dismissKeyguardThenExecute(any(), anyOrNull(), any())
            moveTaskToFront()

            verify(activityStarter).dismissKeyguardThenExecute(any(), anyOrNull(), any())
        }

    @Test
    fun testNewTaskStartsAfterExitingHub_doesNotTriggerUnlock() =
        testScope.runTest {
            transition(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GLANCEABLE_HUB)
            backgroundScope.launch { underTest.waitForActivityStartAndDismissKeyguard() }
            runCurrent()

            transition(from = KeyguardState.GLANCEABLE_HUB, to = KeyguardState.LOCKSCREEN)
            moveTaskToFront()

            verify(activityStarter, never()).dismissKeyguardThenExecute(any(), anyOrNull(), any())
        }

    @Test
    fun testNewTaskStartsAfterTimeout_doesNotTriggerUnlock() =
        testScope.runTest {
            transition(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GLANCEABLE_HUB)
            backgroundScope.launch { underTest.waitForActivityStartAndDismissKeyguard() }
            runCurrent()

            advanceTime(2.seconds)
            moveTaskToFront()

            verify(activityStarter, never()).dismissKeyguardThenExecute(any(), anyOrNull(), any())
        }

    @Test
    fun testActivityResumedWhileOnHub_triggersUnlock() =
        testScope.runTest {
            transition(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GLANCEABLE_HUB)
            backgroundScope.launch { underTest.waitForActivityStartAndDismissKeyguard() }
            runCurrent()

            addActivityEvent(UsageEvents.Event.ACTIVITY_RESUMED)
            advanceTime(1.seconds)

            verify(activityStarter).dismissKeyguardThenExecute(any(), anyOrNull(), any())
        }

    @Test
    fun testActivityResumedAfterExitingHub_doesNotTriggerUnlock() =
        testScope.runTest {
            transition(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GLANCEABLE_HUB)
            backgroundScope.launch { underTest.waitForActivityStartAndDismissKeyguard() }
            runCurrent()

            transition(from = KeyguardState.GLANCEABLE_HUB, to = KeyguardState.LOCKSCREEN)
            addActivityEvent(UsageEvents.Event.ACTIVITY_RESUMED)
            advanceTime(1.seconds)

            verify(activityStarter, never()).dismissKeyguardThenExecute(any(), anyOrNull(), any())
        }

    @Test
    fun testActivityDestroyed_doesNotTriggerUnlock() =
        testScope.runTest {
            transition(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GLANCEABLE_HUB)
            backgroundScope.launch { underTest.waitForActivityStartAndDismissKeyguard() }
            runCurrent()

            addActivityEvent(UsageEvents.Event.ACTIVITY_DESTROYED)
            advanceTime(1.seconds)

            verify(activityStarter, never()).dismissKeyguardThenExecute(any(), anyOrNull(), any())
        }

    @Test
    fun testMultipleActivityEvents_triggersUnlockOnlyOnce() =
        testScope.runTest {
            transition(from = KeyguardState.LOCKSCREEN, to = KeyguardState.GLANCEABLE_HUB)
            backgroundScope.launch { underTest.waitForActivityStartAndDismissKeyguard() }
            runCurrent()

            addActivityEvent(UsageEvents.Event.ACTIVITY_RESUMED)
            advanceTime(10.milliseconds)
            addActivityEvent(UsageEvents.Event.ACTIVITY_RESUMED)
            advanceTime(1.seconds)

            verify(activityStarter, times(1)).dismissKeyguardThenExecute(any(), anyOrNull(), any())
        }

    private fun TestScope.advanceTime(duration: Duration) {
        systemClock.advanceTime(duration.inWholeMilliseconds)
        advanceTimeBy(duration)
    }

    private fun TestScope.addActivityEvent(type: Int) {
        usageStatsRepository.addEvent(
            instanceId = 1,
            user = MAIN_USER.userHandle,
            packageName = "pkg.test",
            timestamp = systemClock.currentTimeMillis(),
            type = type,
        )
        runCurrent()
    }

    private fun TestScope.moveTaskToFront() {
        taskStackChangeListeners.listenerImpl.onTaskMovedToFront(mock<RunningTaskInfo>())
        runCurrent()
    }

    private suspend fun TestScope.transition(from: KeyguardState, to: KeyguardState) {
        keyguardTransitionRepository.sendTransitionSteps(
            listOf(
                TransitionStep(
                    from = from,
                    to = to,
                    value = 0.1f,
                    transitionState = TransitionState.STARTED,
                    ownerName = "test",
                ),
                TransitionStep(
                    from = from,
                    to = to,
                    value = 1f,
                    transitionState = TransitionState.FINISHED,
                    ownerName = "test",
                ),
            ),
            testScope
        )
        runCurrent()
    }

    private companion object {
        val MAIN_USER: UserInfo = UserInfo(0, "primary", UserInfo.FLAG_MAIN)
    }
}
+4 −0
Original line number Diff line number Diff line
@@ -27,7 +27,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.communal.domain.interactor.communalSceneInteractor
import com.android.systemui.communal.domain.interactor.widgetTrampolineInteractor
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.applicationCoroutineScope
import com.android.systemui.kosmos.testScope
import com.android.systemui.log.logcatLogBuffer
import com.android.systemui.plugins.ActivityStarter
@@ -67,9 +69,11 @@ class WidgetInteractionHandlerTest : SysuiTestCase() {
        with(kosmos) {
            underTest =
                WidgetInteractionHandler(
                    applicationScope = applicationCoroutineScope,
                    activityStarter = activityStarter,
                    communalSceneInteractor = communalSceneInteractor,
                    logBuffer = logcatLogBuffer(),
                    widgetTrampolineInteractor = widgetTrampolineInteractor,
                )
        }
    }
+140 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.communal.domain.interactor

import android.app.ActivityManager
import com.android.systemui.common.usagestats.domain.UsageStatsInteractor
import com.android.systemui.common.usagestats.shared.model.ActivityEventModel
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.log.LogBuffer
import com.android.systemui.log.core.Logger
import com.android.systemui.log.dagger.CommunalLog
import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.shared.system.TaskStackChangeListener
import com.android.systemui.shared.system.TaskStackChangeListeners
import com.android.systemui.util.kotlin.race
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout

/**
 * Detects activity starts that occur while the communal hub is showing, within a short delay of a
 * widget interaction occurring. Used for detecting non-activity trampolines which otherwise would
 * not prompt the user for authentication.
 */
@OptIn(ExperimentalCoroutinesApi::class)
@SysUISingleton
class WidgetTrampolineInteractor
@Inject
constructor(
    private val activityStarter: ActivityStarter,
    private val systemClock: SystemClock,
    private val keyguardTransitionInteractor: KeyguardTransitionInteractor,
    private val taskStackChangeListeners: TaskStackChangeListeners,
    private val usageStatsInteractor: UsageStatsInteractor,
    @CommunalLog logBuffer: LogBuffer,
) {
    private companion object {
        const val TAG = "WidgetTrampolineInteractor"
    }

    private val logger = Logger(logBuffer, TAG)

    /** Waits for a new task to be moved to the foreground. */
    private suspend fun waitForNewForegroundTask() = suspendCancellableCoroutine { cont ->
        val listener =
            object : TaskStackChangeListener {
                override fun onTaskMovedToFront(taskInfo: ActivityManager.RunningTaskInfo) {
                    if (!cont.isCompleted) {
                        cont.resume(Unit, null)
                    }
                }
            }
        taskStackChangeListeners.registerTaskStackListener(listener)
        cont.invokeOnCancellation { taskStackChangeListeners.unregisterTaskStackListener(listener) }
    }

    /**
     * Waits for an activity to enter a [ActivityEventModel.Lifecycle.RESUMED] state by periodically
     * polling the system to see if any activities have started.
     */
    private suspend fun waitForActivityStartByPolling(startTime: Long): Boolean {
        while (true) {
            val events = usageStatsInteractor.queryActivityEvents(startTime = startTime)
            if (events.any { event -> event.lifecycle == ActivityEventModel.Lifecycle.RESUMED }) {
                return true
            } else {
                // Poll again in the future to check if an activity started.
                delay(200.milliseconds)
            }
        }
    }

    /** Waits for a transition away from the hub to occur. */
    private suspend fun waitForTransitionAwayFromHub() {
        keyguardTransitionInteractor
            .isFinishedIn(Scenes.Communal, KeyguardState.GLANCEABLE_HUB)
            .takeWhile { it }
            .collect {}
    }

    private suspend fun waitForActivityStartWhileOnHub(): Boolean {
        val startTime = systemClock.currentTimeMillis()
        return try {
            return withTimeout(1.seconds) {
                race(
                    {
                        waitForNewForegroundTask()
                        true
                    },
                    { waitForActivityStartByPolling(startTime) },
                    {
                        waitForTransitionAwayFromHub()
                        false
                    },
                )
            }
        } catch (e: TimeoutCancellationException) {
            false
        }
    }

    /**
     * Checks if an activity starts while on the glanceable hub and dismisses the keyguard if it
     * does. This can detect activities started due to broadcast trampolines from widgets.
     */
    suspend fun waitForActivityStartAndDismissKeyguard() {
        if (waitForActivityStartWhileOnHub()) {
            logger.d("Detected trampoline, requesting unlock")
            activityStarter.dismissKeyguardThenExecute(
                /* action= */ { false },
                /* cancel= */ null,
                /* afterKeyguardGone= */ false
            )
        }
    }
}
+11 −1
Original line number Diff line number Diff line
@@ -48,7 +48,17 @@ constructor(
        InteractionHandlerDelegate(
            communalSceneInteractor,
            findViewToAnimate = { view -> view is SmartspaceAppWidgetHostView },
            intentStarter = this::startIntent,
            intentStarter =
                object : InteractionHandlerDelegate.IntentStarter {
                    override fun startActivity(
                        intent: PendingIntent,
                        fillInIntent: Intent,
                        activityOptions: ActivityOptions,
                        controller: ActivityTransitionAnimator.Controller?
                    ): Boolean {
                        return startIntent(intent, fillInIntent, activityOptions, controller)
                    }
                },
            logger = Logger(logBuffer, TAG),
        )

Loading