diff options
| author | 2023-08-11 16:25:02 +0000 | |
|---|---|---|
| committer | 2023-08-15 20:58:54 +0000 | |
| commit | 1d9496a9bb1e4061e47943d24f566a84b581f266 (patch) | |
| tree | d7d1307bcc4c666679a9570a29e4b676f636dc05 | |
| parent | 886c5e2463af3eb87dfd1c9dededa32b6ccf126e (diff) | |
Show a Stopwatch widget in communal blueprint
This change introduces a keyguard section for widgets in communal mode,
and adds a Stopwatch widget to it, laid out at the bottom right corner.
Bug: 288275889
Test: adb shell cmd statusbar flag widget_on_keyguard on && \
adb shell setprop ctl.restart zygote && \
adb shell cmd statusbar blueprint communal
Test: atest KeyguardWidgetRepositoryImplTest
Test: atest CommunalInteractorTest
Test: atest DefaultCommunalBlueprintTest
Change-Id: Ib8a5a18bd78778c0915ef1796c594e170f2a0b64
19 files changed, 988 insertions, 8 deletions
diff --git a/packages/SystemUI/res/values/ids.xml b/packages/SystemUI/res/values/ids.xml index d2cb475ad2b0..4e72518bc613 100644 --- a/packages/SystemUI/res/values/ids.xml +++ b/packages/SystemUI/res/values/ids.xml @@ -219,4 +219,7 @@ <!-- Privacy dialog --> <item type="id" name="privacy_dialog_close_app_button" /> <item type="id" name="privacy_dialog_manage_app_button" /> + + <!-- Communal mode --> + <item type="id" name="communal_widget_wrapper" /> </resources> diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepository.kt b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepository.kt new file mode 100644 index 000000000000..e2a7d077a32c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepository.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2023 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.data.repository + +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.UserManager +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.common.coroutine.ChannelExt.trySendWithFailureLogging +import com.android.systemui.communal.shared.CommunalAppWidgetInfo +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.core.Logger +import com.android.systemui.log.dagger.CommunalLog +import com.android.systemui.settings.UserTracker +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map + +/** Encapsulates the state of widgets for communal mode. */ +interface CommunalWidgetRepository { + /** A flow of provider info for the stopwatch widget, or null if widget is unavailable. */ + val stopwatchAppWidgetInfo: Flow<CommunalAppWidgetInfo?> +} + +@SysUISingleton +class CommunalWidgetRepositoryImpl +@Inject +constructor( + private val appWidgetManager: AppWidgetManager, + private val appWidgetHost: AppWidgetHost, + broadcastDispatcher: BroadcastDispatcher, + private val packageManager: PackageManager, + private val userManager: UserManager, + private val userTracker: UserTracker, + @CommunalLog logBuffer: LogBuffer, + featureFlags: FeatureFlags, +) : CommunalWidgetRepository { + companion object { + const val TAG = "CommunalWidgetRepository" + const val WIDGET_LABEL = "Stopwatch" + } + + private val logger = Logger(logBuffer, TAG) + + // Whether the [AppWidgetHost] is listening for updates. + private var isHostListening = false + + // Widgets that should be rendered in communal mode. + private val widgets: HashMap<Int, CommunalAppWidgetInfo> = hashMapOf() + + private val isUserUnlocked: Flow<Boolean> = callbackFlow { + if (!featureFlags.isEnabled(Flags.WIDGET_ON_KEYGUARD)) { + awaitClose() + } + + fun isUserUnlockingOrUnlocked(): Boolean { + return userManager.isUserUnlockingOrUnlocked(userTracker.userHandle) + } + + fun send() { + trySendWithFailureLogging(isUserUnlockingOrUnlocked(), TAG) + } + + if (isUserUnlockingOrUnlocked()) { + send() + awaitClose() + } else { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + send() + } + } + + broadcastDispatcher.registerReceiver( + receiver, + IntentFilter(Intent.ACTION_USER_UNLOCKED), + ) + + awaitClose { broadcastDispatcher.unregisterReceiver(receiver) } + } + } + + override val stopwatchAppWidgetInfo: Flow<CommunalAppWidgetInfo?> = + isUserUnlocked.map { isUserUnlocked -> + if (!isUserUnlocked) { + clearWidgets() + stopListening() + return@map null + } + + startListening() + + val providerInfo = + appWidgetManager.installedProviders.find { + it.loadLabel(packageManager).equals(WIDGET_LABEL) + } + + if (providerInfo == null) { + logger.w("Cannot find app widget: $WIDGET_LABEL") + return@map null + } + + return@map addWidget(providerInfo) + } + + private fun startListening() { + if (isHostListening) { + return + } + + appWidgetHost.startListening() + isHostListening = true + } + + private fun stopListening() { + if (!isHostListening) { + return + } + + appWidgetHost.stopListening() + isHostListening = false + } + + private fun addWidget(providerInfo: AppWidgetProviderInfo): CommunalAppWidgetInfo { + val existing = widgets.values.firstOrNull { it.providerInfo == providerInfo } + if (existing != null) { + return existing + } + + val appWidgetId = appWidgetHost.allocateAppWidgetId() + val widget = + CommunalAppWidgetInfo( + providerInfo, + appWidgetId, + ) + widgets[appWidgetId] = widget + return widget + } + + private fun clearWidgets() { + widgets.keys.forEach { appWidgetId -> appWidgetHost.deleteAppWidgetId(appWidgetId) } + widgets.clear() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryModule.kt b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryModule.kt new file mode 100644 index 000000000000..3d1185b79275 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryModule.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2023 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.data.repository + +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetManager +import android.content.Context +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import dagger.Binds +import dagger.Module +import dagger.Provides + +@Module +interface CommunalWidgetRepositoryModule { + companion object { + private const val APP_WIDGET_HOST_ID = 116 + + @SysUISingleton + @Provides + fun provideAppWidgetManager(@Application context: Context): AppWidgetManager { + return AppWidgetManager.getInstance(context) + } + + @SysUISingleton + @Provides + fun provideAppWidgetHost(@Application context: Context): AppWidgetHost { + return AppWidgetHost(context, APP_WIDGET_HOST_ID) + } + } + + @Binds + fun communalWidgetRepository(impl: CommunalWidgetRepositoryImpl): CommunalWidgetRepository +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalInteractor.kt b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalInteractor.kt new file mode 100644 index 000000000000..6dc305e6f826 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/domain/interactor/CommunalInteractor.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2023 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 com.android.systemui.communal.data.repository.CommunalWidgetRepository +import com.android.systemui.communal.shared.CommunalAppWidgetInfo +import com.android.systemui.dagger.SysUISingleton +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +/** Encapsulates business-logic related to communal mode. */ +@SysUISingleton +class CommunalInteractor +@Inject +constructor( + widgetRepository: CommunalWidgetRepository, +) { + /** A flow of info about the widget to be displayed, or null if widget is unavailable. */ + val appWidgetInfo: Flow<CommunalAppWidgetInfo?> = widgetRepository.stopwatchAppWidgetInfo +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/shared/CommunalAppWidgetInfo.kt b/packages/SystemUI/src/com/android/systemui/communal/shared/CommunalAppWidgetInfo.kt new file mode 100644 index 000000000000..0803a01b93b8 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/shared/CommunalAppWidgetInfo.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 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.shared + +import android.appwidget.AppWidgetProviderInfo + +/** A data class that stores info about an app widget that displays in communal mode. */ +data class CommunalAppWidgetInfo( + val providerInfo: AppWidgetProviderInfo, + val appWidgetId: Int, +) diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/adapter/CommunalWidgetViewAdapter.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/adapter/CommunalWidgetViewAdapter.kt new file mode 100644 index 000000000000..2a08d7f6bc28 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/ui/adapter/CommunalWidgetViewAdapter.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2023 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.ui.adapter + +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetManager +import android.content.Context +import android.util.SizeF +import com.android.systemui.communal.shared.CommunalAppWidgetInfo +import com.android.systemui.communal.ui.view.CommunalWidgetWrapper +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.core.Logger +import com.android.systemui.log.dagger.CommunalLog +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** Transforms a [CommunalAppWidgetInfo] to a view that renders the widget. */ +class CommunalWidgetViewAdapter +@Inject +constructor( + @Application private val context: Context, + private val appWidgetManager: AppWidgetManager, + private val appWidgetHost: AppWidgetHost, + @CommunalLog logBuffer: LogBuffer, +) { + companion object { + private const val TAG = "CommunalWidgetViewAdapter" + } + + private val logger = Logger(logBuffer, TAG) + + fun adapt(providerInfoFlow: Flow<CommunalAppWidgetInfo?>): Flow<CommunalWidgetWrapper?> = + providerInfoFlow.map { + if (it == null) { + return@map null + } + + val appWidgetId = it.appWidgetId + val providerInfo = it.providerInfo + + if (appWidgetManager.bindAppWidgetIdIfAllowed(appWidgetId, providerInfo.provider)) { + logger.d("Success binding app widget id: $appWidgetId") + return@map CommunalWidgetWrapper(context).apply { + addView( + appWidgetHost.createView(context, appWidgetId, providerInfo).apply { + // Set the widget to minimum width and height + updateAppWidgetSize( + appWidgetManager.getAppWidgetOptions(appWidgetId), + listOf( + SizeF( + providerInfo.minResizeWidth.toFloat(), + providerInfo.minResizeHeight.toFloat() + ) + ) + ) + } + ) + } + } else { + logger.w("Failed binding app widget id") + return@map null + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/binder/CommunalWidgetViewBinder.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/binder/CommunalWidgetViewBinder.kt new file mode 100644 index 000000000000..1b6d3a8095f0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/ui/binder/CommunalWidgetViewBinder.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2023 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.ui.binder + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.android.systemui.R +import com.android.systemui.communal.ui.adapter.CommunalWidgetViewAdapter +import com.android.systemui.communal.ui.view.CommunalWidgetWrapper +import com.android.systemui.communal.ui.viewmodel.CommunalWidgetViewModel +import com.android.systemui.keyguard.domain.interactor.KeyguardBlueprintInteractor +import com.android.systemui.keyguard.ui.view.KeyguardRootView +import com.android.systemui.lifecycle.repeatWhenAttached +import kotlinx.coroutines.launch + +/** Binds [CommunalWidgetViewModel] to the keyguard root view. */ +object CommunalWidgetViewBinder { + + @JvmStatic + fun bind( + rootView: KeyguardRootView, + viewModel: CommunalWidgetViewModel, + adapter: CommunalWidgetViewAdapter, + keyguardBlueprintInteractor: KeyguardBlueprintInteractor, + ) { + rootView.repeatWhenAttached { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + adapter.adapt(viewModel.appWidgetInfo).collect { + val oldView = + rootView.findViewById<CommunalWidgetWrapper>( + R.id.communal_widget_wrapper + ) + var dirty = false + + if (oldView != null) { + rootView.removeView(oldView) + dirty = true + } + + if (it != null) { + rootView.addView(it) + dirty = true + } + + if (dirty) { + keyguardBlueprintInteractor.refreshBlueprint() + } + } + } + + launch { viewModel.alpha.collect { rootView.alpha = it } } + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/view/CommunalWidgetWrapper.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/view/CommunalWidgetWrapper.kt new file mode 100644 index 000000000000..560f4fac048f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/ui/view/CommunalWidgetWrapper.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 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.ui.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import com.android.systemui.R + +/** Wraps around a widget rendered in communal mode. */ +class CommunalWidgetWrapper(context: Context, attrs: AttributeSet? = null) : + LinearLayout(context, attrs) { + init { + id = R.id.communal_widget_wrapper + } +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/view/layout/blueprints/DefaultCommunalBlueprint.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/view/layout/blueprints/DefaultCommunalBlueprint.kt index bf402749add7..c3369da68821 100644 --- a/packages/SystemUI/src/com/android/systemui/communal/ui/view/layout/blueprints/DefaultCommunalBlueprint.kt +++ b/packages/SystemUI/src/com/android/systemui/communal/ui/view/layout/blueprints/DefaultCommunalBlueprint.kt @@ -17,6 +17,7 @@ package com.android.systemui.communal.ui.view.layout.blueprints import androidx.constraintlayout.widget.ConstraintSet +import com.android.systemui.communal.ui.view.layout.sections.DefaultCommunalWidgetSection import com.android.systemui.dagger.SysUISingleton import com.android.systemui.keyguard.data.repository.KeyguardBlueprint import javax.inject.Inject @@ -24,10 +25,16 @@ import javax.inject.Inject /** Blueprint for communal mode. */ @SysUISingleton @JvmSuppressWildcards -class DefaultCommunalBlueprint @Inject constructor() : KeyguardBlueprint { +class DefaultCommunalBlueprint +@Inject +constructor( + private val defaultCommunalWidgetSection: DefaultCommunalWidgetSection, +) : KeyguardBlueprint { override val id: String = COMMUNAL - override fun apply(constraintSet: ConstraintSet) {} + override fun apply(constraintSet: ConstraintSet) { + defaultCommunalWidgetSection.apply(constraintSet) + } companion object { const val COMMUNAL = "communal" diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/view/layout/sections/DefaultCommunalWidgetSection.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/view/layout/sections/DefaultCommunalWidgetSection.kt new file mode 100644 index 000000000000..b0e3132a1fc7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/ui/view/layout/sections/DefaultCommunalWidgetSection.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 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.ui.view.layout.sections + +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.constraintlayout.widget.ConstraintSet +import androidx.constraintlayout.widget.ConstraintSet.BOTTOM +import androidx.constraintlayout.widget.ConstraintSet.END +import androidx.constraintlayout.widget.ConstraintSet.PARENT_ID +import com.android.systemui.R +import com.android.systemui.keyguard.data.repository.KeyguardSection +import javax.inject.Inject + +class DefaultCommunalWidgetSection @Inject constructor() : KeyguardSection { + private val widgetAreaViewId = R.id.communal_widget_wrapper + + override fun apply(constraintSet: ConstraintSet) { + constraintSet.apply { + constrainWidth(widgetAreaViewId, WRAP_CONTENT) + constrainHeight(widgetAreaViewId, WRAP_CONTENT) + connect(widgetAreaViewId, BOTTOM, PARENT_ID, BOTTOM) + connect(widgetAreaViewId, END, PARENT_ID, END) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalWidgetViewModel.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalWidgetViewModel.kt new file mode 100644 index 000000000000..8fba342c49be --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/CommunalWidgetViewModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2023 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.ui.viewmodel + +import com.android.systemui.communal.domain.interactor.CommunalInteractor +import com.android.systemui.communal.shared.CommunalAppWidgetInfo +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.keyguard.ui.viewmodel.KeyguardBottomAreaViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow + +@SysUISingleton +class CommunalWidgetViewModel +@Inject +constructor( + communalInteractor: CommunalInteractor, + keyguardBottomAreaViewModel: KeyguardBottomAreaViewModel, +) { + /** An observable for the alpha level for the communal widget area. */ + val alpha: Flow<Float> = keyguardBottomAreaViewModel.alpha + + /** A flow of info about the widget to be displayed, or null if widget is unavailable. */ + val appWidgetInfo: Flow<CommunalAppWidgetInfo?> = communalInteractor.appWidgetInfo +} diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardViewConfigurator.kt b/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardViewConfigurator.kt index 8e323d8140d5..9b323ee9a3f3 100644 --- a/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardViewConfigurator.kt +++ b/packages/SystemUI/src/com/android/systemui/keyguard/KeyguardViewConfigurator.kt @@ -24,9 +24,13 @@ import com.android.keyguard.KeyguardStatusViewController import com.android.keyguard.dagger.KeyguardStatusViewComponent import com.android.systemui.CoreStartable import com.android.systemui.R +import com.android.systemui.communal.ui.adapter.CommunalWidgetViewAdapter +import com.android.systemui.communal.ui.binder.CommunalWidgetViewBinder +import com.android.systemui.communal.ui.viewmodel.CommunalWidgetViewModel import com.android.systemui.dagger.SysUISingleton import com.android.systemui.flags.FeatureFlags import com.android.systemui.flags.Flags +import com.android.systemui.keyguard.domain.interactor.KeyguardBlueprintInteractor import com.android.systemui.keyguard.ui.binder.KeyguardAmbientIndicationAreaViewBinder import com.android.systemui.keyguard.ui.binder.KeyguardBlueprintViewBinder import com.android.systemui.keyguard.ui.binder.KeyguardIndicationAreaBinder @@ -83,6 +87,9 @@ constructor( private val keyguardBlueprintCommandListener: KeyguardBlueprintCommandListener, private val keyguardBlueprintViewModel: KeyguardBlueprintViewModel, private val keyguardStatusViewComponentFactory: KeyguardStatusViewComponent.Factory, + private val keyguardBlueprintInteractor: KeyguardBlueprintInteractor, + private val communalWidgetViewModel: CommunalWidgetViewModel, + private val communalWidgetViewAdapter: CommunalWidgetViewAdapter, ) : CoreStartable { private var rootViewHandle: DisposableHandle? = null @@ -106,6 +113,7 @@ constructor( bindRightShortcut() bindAmbientIndicationArea() bindSettingsPopupMenu() + bindCommunalWidgetArea() KeyguardBlueprintViewBinder.bind(keyguardRootView, keyguardBlueprintViewModel) keyguardBlueprintCommandListener.start() @@ -279,6 +287,19 @@ constructor( } } + private fun bindCommunalWidgetArea() { + if (!featureFlags.isEnabled(Flags.WIDGET_ON_KEYGUARD)) { + return + } + + CommunalWidgetViewBinder.bind( + keyguardRootView, + communalWidgetViewModel, + communalWidgetViewAdapter, + keyguardBlueprintInteractor, + ) + } + /** * Temporary, to allow NotificationPanelViewController to use the same instance while code is * migrated: b/288242803 diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/dagger/KeyguardModule.java b/packages/SystemUI/src/com/android/systemui/keyguard/dagger/KeyguardModule.java index 9a44230cf185..13dfe24e7e19 100644 --- a/packages/SystemUI/src/com/android/systemui/keyguard/dagger/KeyguardModule.java +++ b/packages/SystemUI/src/com/android/systemui/keyguard/dagger/KeyguardModule.java @@ -37,6 +37,7 @@ import com.android.systemui.animation.ActivityLaunchAnimator; import com.android.systemui.broadcast.BroadcastDispatcher; import com.android.systemui.classifier.FalsingCollector; import com.android.systemui.classifier.FalsingModule; +import com.android.systemui.communal.data.repository.CommunalWidgetRepositoryModule; import com.android.systemui.dagger.SysUISingleton; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dagger.qualifiers.UiBackground; @@ -75,11 +76,12 @@ import com.android.systemui.util.time.SystemClock; import com.android.systemui.wallpapers.data.repository.WallpaperRepository; import com.android.wm.shell.keyguard.KeyguardTransitions; -import java.util.concurrent.Executor; - import dagger.Lazy; import dagger.Module; import dagger.Provides; + +import java.util.concurrent.Executor; + import kotlinx.coroutines.CoroutineDispatcher; /** @@ -91,6 +93,7 @@ import kotlinx.coroutines.CoroutineDispatcher; KeyguardStatusViewComponent.class, KeyguardUserSwitcherComponent.class}, includes = { + CommunalWidgetRepositoryModule.class, FalsingModule.class, KeyguardDataQuickAffordanceModule.class, KeyguardRepositoryModule.class, diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/CommunalLog.kt b/packages/SystemUI/src/com/android/systemui/log/dagger/CommunalLog.kt new file mode 100644 index 000000000000..afb18f1f23c4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/log/dagger/CommunalLog.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2023 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.log.dagger + +import javax.inject.Qualifier + +/** A [com.android.systemui.log.LogBuffer] for communal-related logging. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class CommunalLog diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java index cc1504a1df97..cbb29cd9ea63 100644 --- a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java +++ b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java @@ -489,6 +489,16 @@ public class LogModule { return factory.create("DreamLog", 250); } + /** + * Provides a {@link LogBuffer} for communal-related logs. + */ + @Provides + @SysUISingleton + @CommunalLog + public static LogBuffer provideCommunalLogBuffer(LogBufferFactory factory) { + return factory.create("CommunalLog", 250); + } + /** Provides a {@link LogBuffer} for display metrics related logs. */ @Provides @SysUISingleton diff --git a/packages/SystemUI/tests/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryImplTest.kt new file mode 100644 index 000000000000..3df9cbb29e4a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/communal/data/repository/CommunalWidgetRepositoryImplTest.kt @@ -0,0 +1,285 @@ +package com.android.systemui.communal.data.repository + +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.BroadcastReceiver +import android.content.pm.PackageManager +import android.os.UserHandle +import android.os.UserManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.log.LogBuffer +import com.android.systemui.log.core.FakeLogBuffer +import com.android.systemui.settings.UserTracker +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.kotlinArgumentCaptor +import com.android.systemui.util.mockito.nullable +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +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.Mock +import org.mockito.Mockito +import org.mockito.Mockito.anyInt +import org.mockito.MockitoAnnotations + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(AndroidJUnit4::class) +class CommunalWidgetRepositoryImplTest : SysuiTestCase() { + @Mock private lateinit var appWidgetManager: AppWidgetManager + + @Mock private lateinit var appWidgetHost: AppWidgetHost + + @Mock private lateinit var broadcastDispatcher: BroadcastDispatcher + + @Mock private lateinit var packageManager: PackageManager + + @Mock private lateinit var userManager: UserManager + + @Mock private lateinit var userHandle: UserHandle + + @Mock private lateinit var userTracker: UserTracker + + @Mock private lateinit var featureFlags: FeatureFlags + + @Mock private lateinit var stopwatchProviderInfo: AppWidgetProviderInfo + + private lateinit var logBuffer: LogBuffer + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + logBuffer = FakeLogBuffer.Factory.create() + + featureFlagEnabled(true) + whenever(stopwatchProviderInfo.loadLabel(any())).thenReturn("Stopwatch") + whenever(userTracker.userHandle).thenReturn(userHandle) + } + + @Test + fun broadcastReceiver_featureDisabled_doNotRegisterUserUnlockedBroadcastReceiver() = + testScope.runTest { + featureFlagEnabled(false) + val repository = initCommunalWidgetRepository() + collectLastValue(repository.stopwatchAppWidgetInfo)() + verifyBroadcastReceiverNeverRegistered() + } + + @Test + fun broadcastReceiver_featureEnabledAndUserUnlocked_doNotRegisterBroadcastReceiver() = + testScope.runTest { + userUnlocked(true) + val repository = initCommunalWidgetRepository() + collectLastValue(repository.stopwatchAppWidgetInfo)() + verifyBroadcastReceiverNeverRegistered() + } + + @Test + fun broadcastReceiver_featureEnabledAndUserLocked_registerBroadcastReceiver() = + testScope.runTest { + userUnlocked(false) + val repository = initCommunalWidgetRepository() + collectLastValue(repository.stopwatchAppWidgetInfo)() + verifyBroadcastReceiverRegistered() + } + + @Test + fun broadcastReceiver_whenFlowFinishes_unregisterBroadcastReceiver() = + testScope.runTest { + userUnlocked(false) + val repository = initCommunalWidgetRepository() + + val job = launch { repository.stopwatchAppWidgetInfo.collect() } + runCurrent() + val receiver = broadcastReceiverUpdate() + + job.cancel() + runCurrent() + + Mockito.verify(broadcastDispatcher).unregisterReceiver(receiver) + } + + @Test + fun stopwatch_whenUserUnlocks_receiveProviderInfo() = + testScope.runTest { + userUnlocked(false) + val repository = initCommunalWidgetRepository() + val lastStopwatchProviderInfo = collectLastValue(repository.stopwatchAppWidgetInfo) + assertThat(lastStopwatchProviderInfo()).isNull() + + userUnlocked(true) + installedProviders(listOf(stopwatchProviderInfo)) + broadcastReceiverUpdate() + + assertThat(lastStopwatchProviderInfo()?.providerInfo).isEqualTo(stopwatchProviderInfo) + } + + @Test + fun stopwatch_userUnlockedButWidgetNotInstalled_noProviderInfo() = + testScope.runTest { + userUnlocked(true) + installedProviders(listOf()) + + val repository = initCommunalWidgetRepository() + + val lastStopwatchProviderInfo = collectLastValue(repository.stopwatchAppWidgetInfo) + assertThat(lastStopwatchProviderInfo()).isNull() + } + + @Test + fun appWidgetId_providerInfoAvailable_allocateAppWidgetId() = + testScope.runTest { + userUnlocked(true) + installedProviders(listOf(stopwatchProviderInfo)) + val repository = initCommunalWidgetRepository() + collectLastValue(repository.stopwatchAppWidgetInfo)() + Mockito.verify(appWidgetHost).allocateAppWidgetId() + } + + @Test + fun appWidgetId_userLockedAgainAfterProviderInfoAvailable_deleteAppWidgetId() = + testScope.runTest { + whenever(appWidgetHost.allocateAppWidgetId()).thenReturn(123456) + userUnlocked(false) + val repository = initCommunalWidgetRepository() + val lastStopwatchProviderInfo = collectLastValue(repository.stopwatchAppWidgetInfo) + assertThat(lastStopwatchProviderInfo()).isNull() + + // User unlocks + userUnlocked(true) + installedProviders(listOf(stopwatchProviderInfo)) + broadcastReceiverUpdate() + + // Verify app widget id allocated + assertThat(lastStopwatchProviderInfo()?.appWidgetId).isEqualTo(123456) + Mockito.verify(appWidgetHost).allocateAppWidgetId() + Mockito.verify(appWidgetHost, Mockito.never()).deleteAppWidgetId(anyInt()) + + // User locked again + userUnlocked(false) + broadcastReceiverUpdate() + + // Verify app widget id deleted + assertThat(lastStopwatchProviderInfo()).isNull() + Mockito.verify(appWidgetHost).deleteAppWidgetId(123456) + } + + @Test + fun appWidgetHost_userUnlocked_startListening() = + testScope.runTest { + userUnlocked(false) + val repository = initCommunalWidgetRepository() + collectLastValue(repository.stopwatchAppWidgetInfo)() + Mockito.verify(appWidgetHost, Mockito.never()).startListening() + + userUnlocked(true) + broadcastReceiverUpdate() + collectLastValue(repository.stopwatchAppWidgetInfo)() + + Mockito.verify(appWidgetHost).startListening() + } + + @Test + fun appWidgetHost_userLockedAgain_stopListening() = + testScope.runTest { + userUnlocked(false) + val repository = initCommunalWidgetRepository() + collectLastValue(repository.stopwatchAppWidgetInfo)() + + userUnlocked(true) + broadcastReceiverUpdate() + collectLastValue(repository.stopwatchAppWidgetInfo)() + + Mockito.verify(appWidgetHost).startListening() + Mockito.verify(appWidgetHost, Mockito.never()).stopListening() + + userUnlocked(false) + broadcastReceiverUpdate() + collectLastValue(repository.stopwatchAppWidgetInfo)() + + Mockito.verify(appWidgetHost).stopListening() + } + + private fun initCommunalWidgetRepository(): CommunalWidgetRepositoryImpl { + return CommunalWidgetRepositoryImpl( + appWidgetManager, + appWidgetHost, + broadcastDispatcher, + packageManager, + userManager, + userTracker, + logBuffer, + featureFlags, + ) + } + + private fun verifyBroadcastReceiverRegistered() { + Mockito.verify(broadcastDispatcher) + .registerReceiver( + any(), + any(), + nullable(), + nullable(), + anyInt(), + nullable(), + ) + } + + private fun verifyBroadcastReceiverNeverRegistered() { + Mockito.verify(broadcastDispatcher, Mockito.never()) + .registerReceiver( + any(), + any(), + nullable(), + nullable(), + anyInt(), + nullable(), + ) + } + + private fun broadcastReceiverUpdate(): BroadcastReceiver { + val broadcastReceiverCaptor = kotlinArgumentCaptor<BroadcastReceiver>() + Mockito.verify(broadcastDispatcher) + .registerReceiver( + broadcastReceiverCaptor.capture(), + any(), + nullable(), + nullable(), + anyInt(), + nullable(), + ) + broadcastReceiverCaptor.value.onReceive(null, null) + return broadcastReceiverCaptor.value + } + + private fun featureFlagEnabled(enabled: Boolean) { + whenever(featureFlags.isEnabled(Flags.WIDGET_ON_KEYGUARD)).thenReturn(enabled) + } + + private fun userUnlocked(userUnlocked: Boolean) { + whenever(userManager.isUserUnlockingOrUnlocked(userHandle)).thenReturn(userUnlocked) + } + + private fun installedProviders(providers: List<AppWidgetProviderInfo>) { + whenever(appWidgetManager.installedProviders).thenReturn(providers) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/communal/domain/interactor/CommunalInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/communal/domain/interactor/CommunalInteractorTest.kt new file mode 100644 index 000000000000..d28f530f04f3 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/communal/domain/interactor/CommunalInteractorTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2023 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 androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.RoboPilotTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.communal.data.repository.FakeCommunalWidgetRepository +import com.android.systemui.communal.shared.CommunalAppWidgetInfo +import com.android.systemui.coroutines.collectLastValue +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +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.Mock +import org.mockito.MockitoAnnotations + +@SmallTest +@RoboPilotTest +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class CommunalInteractorTest : SysuiTestCase() { + @Mock private lateinit var stopwatchAppWidgetInfo: CommunalAppWidgetInfo + + private lateinit var testScope: TestScope + + private lateinit var widgetRepository: FakeCommunalWidgetRepository + private lateinit var interactor: CommunalInteractor + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + + testScope = TestScope() + widgetRepository = FakeCommunalWidgetRepository() + interactor = CommunalInteractor(widgetRepository) + } + + @Test + fun testAppWidgetInfoFlow() = + testScope.runTest { + val lastAppWidgetInfo = collectLastValue(interactor.appWidgetInfo) + runCurrent() + assertThat(lastAppWidgetInfo()).isNull() + + widgetRepository.setStopwatchAppWidgetInfo(stopwatchAppWidgetInfo) + runCurrent() + assertThat(lastAppWidgetInfo()).isEqualTo(stopwatchAppWidgetInfo) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/communal/ui/view/layout/blueprints/DefaultCommunalBlueprintTest.kt b/packages/SystemUI/tests/src/com/android/systemui/communal/ui/view/layout/blueprints/DefaultCommunalBlueprintTest.kt index 783bb47bb9b0..e3a75f161318 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/communal/ui/view/layout/blueprints/DefaultCommunalBlueprintTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/communal/ui/view/layout/blueprints/DefaultCommunalBlueprintTest.kt @@ -5,25 +5,32 @@ import android.testing.TestableLooper import androidx.constraintlayout.widget.ConstraintSet import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase +import com.android.systemui.communal.ui.view.layout.sections.DefaultCommunalWidgetSection import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations @RunWith(AndroidTestingRunner::class) @TestableLooper.RunWithLooper(setAsMainLooper = true) @SmallTest class DefaultCommunalBlueprintTest : SysuiTestCase() { + @Mock private lateinit var widgetSection: DefaultCommunalWidgetSection + private lateinit var blueprint: DefaultCommunalBlueprint @Before fun setup() { - blueprint = DefaultCommunalBlueprint() + MockitoAnnotations.initMocks(this) + blueprint = DefaultCommunalBlueprint(widgetSection) } @Test - fun apply_doesNothing() { + fun apply() { val cs = ConstraintSet() blueprint.apply(cs) - // Nothing happens yet. + verify(widgetSection).apply(cs) } -}
\ No newline at end of file +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalWidgetRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalWidgetRepository.kt new file mode 100644 index 000000000000..1a8c5830e453 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/data/repository/FakeCommunalWidgetRepository.kt @@ -0,0 +1,15 @@ +package com.android.systemui.communal.data.repository + +import com.android.systemui.communal.shared.CommunalAppWidgetInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** Fake implementation of [CommunalWidgetRepository] */ +class FakeCommunalWidgetRepository : CommunalWidgetRepository { + private val _stopwatchAppWidgetInfo = MutableStateFlow<CommunalAppWidgetInfo?>(null) + override val stopwatchAppWidgetInfo: Flow<CommunalAppWidgetInfo?> = _stopwatchAppWidgetInfo + + fun setStopwatchAppWidgetInfo(appWidgetInfo: CommunalAppWidgetInfo) { + _stopwatchAppWidgetInfo.value = appWidgetInfo + } +} |