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

Commit 82e14c21 authored by Nicolo' Mazzucato's avatar Nicolo' Mazzucato
Browse files

Introduce PerDisplayInstanceRepository (and use it for SysUIState)

This allows to create display scoped instances elegantly, and is
supposed to replace PerDisplayStore. This cl is not aimed at refactoring
all the code using PerDisplayStore (it will happen in a follow up).

SysUIState is now only provided by the repository, and cached until
displays are removed.

Bug: 362719719
Bug: 398011576
Test: SysUiStateTest, PerDisplayInstanceRepositoryImplTest
Flag: com.android.systemui.shade_window_goes_around
Change-Id: I3d8a8ae4cfc54014772f8831b386bc6aadf2fa96
parent 5d44d8f8
Loading
Loading
Loading
Loading
+110 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.display.data.repository

import android.view.Display
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
@SmallTest
class PerDisplayInstanceRepositoryImplTest : SysuiTestCase() {

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val testScope = kosmos.testScope
    private val fakeDisplayRepository = kosmos.displayRepository
    private val fakePerDisplayInstanceProviderWithTeardown =
        kosmos.fakePerDisplayInstanceProviderWithTeardown

    private val underTest: PerDisplayInstanceRepositoryImpl<TestPerDisplayInstance> =
        kosmos.fakePerDisplayInstanceRepository

    @Before
    fun addDisplays() = runBlocking {
        fakeDisplayRepository += createDisplay(DEFAULT_DISPLAY_ID)
        fakeDisplayRepository += createDisplay(NON_DEFAULT_DISPLAY_ID)
    }

    @Test
    fun forDisplay_defaultDisplay_multipleCalls_returnsSameInstance() =
        testScope.runTest {
            val instance = underTest[DEFAULT_DISPLAY_ID]

            assertThat(underTest[DEFAULT_DISPLAY_ID]).isSameInstanceAs(instance)
        }

    @Test
    fun forDisplay_nonDefaultDisplay_multipleCalls_returnsSameInstance() =
        testScope.runTest {
            val instance = underTest[NON_DEFAULT_DISPLAY_ID]

            assertThat(underTest[NON_DEFAULT_DISPLAY_ID]).isSameInstanceAs(instance)
        }

    @Test
    fun forDisplay_nonDefaultDisplay_afterDisplayRemoved_returnsNewInstance() =
        testScope.runTest {
            val instance = underTest[NON_DEFAULT_DISPLAY_ID]

            fakeDisplayRepository -= NON_DEFAULT_DISPLAY_ID
            fakeDisplayRepository += createDisplay(NON_DEFAULT_DISPLAY_ID)

            assertThat(underTest[NON_DEFAULT_DISPLAY_ID]).isNotSameInstanceAs(instance)
        }

    @Test
    fun forDisplay_nonExistingDisplayId_returnsNull() =
        testScope.runTest { assertThat(underTest[NON_EXISTING_DISPLAY_ID]).isNull() }

    @Test
    fun forDisplay_afterDisplayRemoved_destroyInstanceInvoked() =
        testScope.runTest {
            val instance = underTest[NON_DEFAULT_DISPLAY_ID]

            fakeDisplayRepository -= NON_DEFAULT_DISPLAY_ID

            assertThat(fakePerDisplayInstanceProviderWithTeardown.destroyed)
                .containsExactly(instance)
        }

    @Test
    fun forDisplay_withoutDisplayRemoval_destroyInstanceIsNotInvoked() =
        testScope.runTest {
            underTest[NON_DEFAULT_DISPLAY_ID]

            assertThat(fakePerDisplayInstanceProviderWithTeardown.destroyed).isEmpty()
        }

    private fun createDisplay(displayId: Int): Display =
        display(type = Display.TYPE_INTERNAL, id = displayId)

    companion object {
        private const val DEFAULT_DISPLAY_ID = Display.DEFAULT_DISPLAY
        private const val NON_DEFAULT_DISPLAY_ID = DEFAULT_DISPLAY_ID + 1
        private const val NON_EXISTING_DISPLAY_ID = DEFAULT_DISPLAY_ID + 2
    }
}
+19 −4
Original line number Diff line number Diff line
@@ -19,6 +19,9 @@ package com.android.systemui.model;

import static android.view.Display.DEFAULT_DISPLAY;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
@@ -29,8 +32,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;

import com.android.systemui.SysuiTestCase;
import com.android.systemui.dump.DumpManager;
import com.android.systemui.kosmos.KosmosJavaAdapter;
import com.android.systemui.settings.DisplayTracker;

import org.junit.Before;
import org.junit.Test;
@@ -48,11 +51,11 @@ public class SysUiStateTest extends SysuiTestCase {
    private KosmosJavaAdapter mKosmos;
    private SysUiState.SysUiStateCallback mCallback;
    private SysUiState mFlagsContainer;
    private DisplayTracker mDisplayTracker;
    private SceneContainerPlugin mSceneContainerPlugin;
    private DumpManager mDumpManager;

    private SysUiState createInstance(int displayId) {
        var sysuiState = new SysUiStateImpl(displayId, mSceneContainerPlugin);
        var sysuiState = new SysUiStateImpl(displayId, mSceneContainerPlugin, mDumpManager);
        sysuiState.addCallback(mCallback);
        return sysuiState;
    }
@@ -60,10 +63,10 @@ public class SysUiStateTest extends SysuiTestCase {
    @Before
    public void setup() {
        mKosmos = new KosmosJavaAdapter(this);
        mDisplayTracker = mKosmos.getDisplayTracker();
        mFlagsContainer = mKosmos.getSysuiState();
        mSceneContainerPlugin = mKosmos.getSceneContainerPlugin();
        mCallback = mock(SysUiState.SysUiStateCallback.class);
        mDumpManager = mock(DumpManager.class);
        mFlagsContainer = createInstance(DEFAULT_DISPLAY);
    }

@@ -139,6 +142,18 @@ public class SysUiStateTest extends SysuiTestCase {
        verify(mCallback, never()).onSystemUiStateChanged(FLAG_1);
    }

    @Test
    public void init_registersWithDumpManager() {
        verify(mDumpManager).registerNormalDumpable(any(), eq(mFlagsContainer));
    }

    @Test
    public void destroy_unregistersWithDumpManager() {
        mFlagsContainer.destroy();

        verify(mDumpManager).unregisterDumpable(anyString());
    }

    private void setFlags(int... flags) {
        setFlags(mFlagsContainer, flags);
    }
+45 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.dagger

import com.android.systemui.display.data.repository.DefaultDisplayOnlyInstanceRepositoryImpl
import com.android.systemui.display.data.repository.PerDisplayInstanceRepositoryImpl
import com.android.systemui.display.data.repository.PerDisplayRepository
import com.android.systemui.model.SysUIStateInstanceProvider
import com.android.systemui.model.SysUiState
import com.android.systemui.shade.shared.flag.ShadeWindowGoesAround
import dagger.Module
import dagger.Provides

/** This module is meant to contain all the code to create the various [PerDisplayRepository<>]. */
@Module
class PerDisplayRepositoriesModule {

    @SysUISingleton
    @Provides
    fun provideSysUiStateRepository(
        repositoryFactory: PerDisplayInstanceRepositoryImpl.Factory<SysUiState>,
        instanceProvider: SysUIStateInstanceProvider,
    ): PerDisplayRepository<SysUiState> {
        val debugName = "SysUiStatePerDisplayRepo"
        return if (ShadeWindowGoesAround.isEnabled) {
            repositoryFactory.create(debugName, instanceProvider)
        } else {
            DefaultDisplayOnlyInstanceRepositoryImpl(debugName, instanceProvider)
        }
    }
}
+5 −8
Original line number Diff line number Diff line
@@ -65,9 +65,9 @@ import com.android.systemui.dagger.qualifiers.UiBackground;
import com.android.systemui.demomode.dagger.DemoModeModule;
import com.android.systemui.deviceentry.DeviceEntryModule;
import com.android.systemui.display.DisplayModule;
import com.android.systemui.display.data.repository.PerDisplayRepository;
import com.android.systemui.doze.dagger.DozeComponent;
import com.android.systemui.dreams.dagger.DreamModule;
import com.android.systemui.dump.DumpManager;
import com.android.systemui.flags.FeatureFlags;
import com.android.systemui.flags.FlagDependenciesModule;
import com.android.systemui.flags.FlagsModule;
@@ -88,7 +88,6 @@ import com.android.systemui.mediaprojection.appselector.MediaProjectionActivitie
import com.android.systemui.mediaprojection.taskswitcher.MediaProjectionTaskSwitcherModule;
import com.android.systemui.mediarouter.MediaRouterModule;
import com.android.systemui.model.SysUiState;
import com.android.systemui.model.SysUiStateImpl;
import com.android.systemui.motiontool.MotionToolModule;
import com.android.systemui.navigationbar.NavigationBarComponent;
import com.android.systemui.navigationbar.gestural.dagger.GestureModule;
@@ -289,7 +288,8 @@ import javax.inject.Named;
        UtilModule.class,
        NoteTaskModule.class,
        WalletModule.class,
        LowLightModule.class
        LowLightModule.class,
        PerDisplayRepositoriesModule.class
},
        subcomponents = {
                ComplicationComponent.class,
@@ -326,11 +326,8 @@ public abstract class SystemUIModule {
    @SysUISingleton
    @Provides
    static SysUiState provideSysUiState(
            DumpManager dumpManager,
            SysUiStateImpl.Factory sysUiStateFactory) {
        final SysUiState state = sysUiStateFactory.create(Display.DEFAULT_DISPLAY);
        dumpManager.registerDumpable(state);
        return state;
            PerDisplayRepository<SysUiState> repository) {
        return repository.get(Display.DEFAULT_DISPLAY);
    }

    /**
+191 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.display.data.repository

import android.util.Log
import android.view.Display
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.app.tracing.traceSection
import com.android.systemui.Dumpable
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.dump.DumpManager
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import java.io.PrintWriter
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest

/**
 * Used to create instances of type `T` for a specific display.
 *
 * This is useful for resources or objects that need to be managed independently for each connected
 * display (e.g., UI state, rendering contexts, or display-specific configurations).
 *
 * Note that in most cases this can be implemented by a simple `@AssistedFactory` with `displayId`
 * parameter
 *
 * ```kotlin
 * class SomeType @AssistedInject constructor(@Assisted displayId: Int,..)
 *      @AssistedFactory
 *      interface Factory {
 *         fun create(displayId: Int): SomeType
 *      }
 *  }
 * ```
 *
 * Then it can be used to create a [PerDisplayRepository] as follows:
 * ```kotlin
 * // Injected:
 * val repositoryFactory: PerDisplayRepositoryImpl.Factory
 * val instanceFactory: PerDisplayRepositoryImpl.Factory
 * // repository creation:
 * repositoryFactory.create(instanceFactory::create)
 * ```
 *
 * @see PerDisplayRepository For how to retrieve and manage instances created by this factory.
 */
fun interface PerDisplayInstanceProvider<T> {
    /** Creates an instance for a display. */
    fun createInstance(displayId: Int): T?
}

/**
 * Extends [PerDisplayInstanceProvider], adding support for destroying the instance.
 *
 * This is useful for releasing resources associated with a display when it is disconnected or when
 * the per-display instance is no longer needed.
 */
interface PerDisplayInstanceProviderWithTeardown<T> : PerDisplayInstanceProvider<T> {
    /** Destroys a previously created instance of `T` forever. */
    fun destroyInstance(instance: T)
}

/**
 * Provides access to per-display instances of type `T`.
 *
 * Acts as a repository, managing the caching and retrieval of instances created by a
 * [PerDisplayInstanceProvider]. It ensures that only one instance of `T` exists per display ID.
 */
interface PerDisplayRepository<T> {
    /** Gets the cached instance or create a new one for a given display. */
    operator fun get(displayId: Int): T?

    /** Debug name for this repository, mainly for tracing and logging. */
    val debugName: String
}

/**
 * Default implementation of [PerDisplayRepository].
 *
 * This class manages a cache of per-display instances of type `T`, creating them using a provided
 * [PerDisplayInstanceProvider] and optionally tearing them down using a
 * [PerDisplayInstanceProviderWithTeardown] when displays are disconnected.
 *
 * It listens to the [DisplayRepository] to detect when displays are added or removed, and
 * automatically manages the lifecycle of the per-display instances.
 *
 * Note that this is a [PerDisplayStoreImpl] 2.0 that doesn't require [CoreStartable] bindings,
 * providing all args in the constructor.
 */
class PerDisplayInstanceRepositoryImpl<T>
@AssistedInject
constructor(
    @Assisted override val debugName: String,
    @Assisted private val instanceProvider: PerDisplayInstanceProvider<T>,
    @Background private val backgroundApplicationScope: CoroutineScope,
    private val displayRepository: DisplayRepository,
    private val dumpManager: DumpManager,
) : PerDisplayRepository<T>, Dumpable {

    private val perDisplayInstances = ConcurrentHashMap<Int, T?>()

    init {
        backgroundApplicationScope.launch("$debugName#start") { start() }
    }

    private suspend fun start() {
        dumpManager.registerDumpable(this)
        displayRepository.displayIds.collectLatest { displayIds ->
            val toRemove = perDisplayInstances.keys - displayIds
            toRemove.forEach { displayId ->
                perDisplayInstances.remove(displayId)?.let { instance ->
                    (instanceProvider as? PerDisplayInstanceProviderWithTeardown)?.destroyInstance(
                        instance
                    )
                }
            }
        }
    }

    override fun get(displayId: Int): T? {
        if (displayRepository.getDisplay(displayId) == null) {
            Log.e(TAG, "<$debugName: Display with id $displayId doesn't exist.")
            return null
        }

        // If it doesn't exist, create it and put it in the map.
        return perDisplayInstances.computeIfAbsent(displayId) { key ->
            val instance =
                traceSection({ "creating instance of $debugName for displayId=$key" }) {
                    instanceProvider.createInstance(key)
                }
            if (instance == null) {
                Log.e(
                    TAG,
                    "<$debugName> returning null because createInstance($key) returned null.",
                )
            }
            instance
        }
    }

    @AssistedFactory
    interface Factory<T> {
        fun create(
            debugName: String,
            instanceProvider: PerDisplayInstanceProvider<T>,
        ): PerDisplayInstanceRepositoryImpl<T>
    }

    companion object {
        private const val TAG = "PerDisplayInstanceRepo"
    }

    override fun dump(pw: PrintWriter, args: Array<out String>) {
        pw.println(perDisplayInstances)
    }
}

/**
 * Provides an instance of a given class **only** for the default display, even if asked for another
 * display.
 *
 * This is useful in case of flag refactors: it can be provided instead of an instance of
 * [PerDisplayInstanceRepositoryImpl] when a flag related to multi display refactoring is off.
 */
class DefaultDisplayOnlyInstanceRepositoryImpl<T>(
    override val debugName: String,
    private val instanceProvider: PerDisplayInstanceProvider<T>,
) : PerDisplayRepository<T> {
    private val lazyDefaultDisplayInstance by lazy {
        instanceProvider.createInstance(Display.DEFAULT_DISPLAY)
    }

    override fun get(displayId: Int): T? = lazyDefaultDisplayInstance
}
Loading