1 /* 2 * Copyright 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.glance.appwidget 18 19 import android.app.Activity 20 import android.appwidget.AppWidgetHost 21 import android.appwidget.AppWidgetHostView 22 import android.appwidget.AppWidgetManager 23 import android.appwidget.AppWidgetProviderInfo 24 import android.content.ComponentName 25 import android.content.Context 26 import android.content.res.Configuration 27 import android.graphics.Color 28 import android.graphics.Rect 29 import android.os.Bundle 30 import android.os.LocaleList 31 import android.util.Log 32 import android.view.Gravity 33 import android.view.View 34 import android.view.WindowManager 35 import android.widget.FrameLayout 36 import android.widget.RemoteViews 37 import androidx.annotation.RequiresApi 38 import androidx.compose.ui.unit.DpSize 39 import androidx.compose.ui.unit.dp 40 import androidx.glance.appwidget.test.R 41 import java.util.Locale 42 import java.util.concurrent.CountDownLatch 43 import java.util.concurrent.TimeUnit 44 import org.junit.Assert.fail 45 46 private const val TAG = "AppWidgetHostTestActivity" 47 48 @RequiresApi(26) 49 class AppWidgetHostTestActivity : Activity() { 50 private var mHost: AppWidgetHost? = null 51 private val mHostViews = mutableListOf<TestAppWidgetHostView>() 52 private var mConfigurationChanged: CountDownLatch? = null 53 private var mLastConfiguration: Configuration? = null 54 val lastConfiguration: Configuration <lambda>null55 get() = synchronized(this) { mLastConfiguration!! } 56 onCreatenull57 override fun onCreate(savedInstanceState: Bundle?) { 58 super.onCreate(savedInstanceState) 59 window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 60 setContentView(R.layout.app_widget_host_activity) 61 62 mHost = TestAppWidgetHost(this, 1025).also { it.startListening() } 63 } 64 onDestroynull65 override fun onDestroy() { 66 try { 67 mHost?.stopListening() 68 } catch (ex: Throwable) { 69 Log.w(TAG, "Error stopping listening", ex) 70 } 71 try { 72 mHost?.deleteHost() 73 } catch (t: Throwable) { 74 Log.w(TAG, "Error deleting Host", t) 75 } 76 mHost = null 77 super.onDestroy() 78 } 79 bindAppWidgetnull80 fun bindAppWidget(portraitSize: DpSize, landscapeSize: DpSize): TestAppWidgetHostView { 81 val host = mHost ?: error("App widgets can only be bound while the activity is created") 82 83 val appWidgetManager = AppWidgetManager.getInstance(this) 84 val appWidgetId = host.allocateAppWidgetId() 85 val componentName = ComponentName(this, TestGlanceAppWidgetReceiver::class.java) 86 87 val wasBound = 88 appWidgetManager.bindAppWidgetIdIfAllowed( 89 appWidgetId, 90 componentName, 91 optionsBundleOf(listOf(portraitSize, landscapeSize)) 92 ) 93 if (!wasBound) { 94 fail("Failed to bind the app widget") 95 } 96 97 val info = appWidgetManager.getAppWidgetInfo(appWidgetId) 98 val locale = Locale.getDefault() 99 val config = resources.configuration 100 config.setLocales(LocaleList(locale)) 101 config.setLayoutDirection(locale) 102 val context = this.createConfigurationContext(config) 103 104 val hostView = host.createView(context, appWidgetId, info) as TestAppWidgetHostView 105 val contentFrame = findViewById<FrameLayout>(R.id.content) 106 contentFrame.addView(hostView) 107 hostView.setSizes(portraitSize, landscapeSize) 108 hostView.setBackgroundColor(Color.WHITE) 109 mHostViews += hostView 110 return hostView 111 } 112 deleteAppWidgetnull113 fun deleteAppWidget(hostView: TestAppWidgetHostView) { 114 val appWidgetId = hostView.appWidgetId 115 mHost?.deleteAppWidgetId(appWidgetId) 116 mHostViews.remove(hostView) 117 val contentFrame = findViewById<FrameLayout>(R.id.content) 118 contentFrame.removeView(hostView) 119 } 120 onConfigurationChangednull121 override fun onConfigurationChanged(newConfig: Configuration) { 122 super.onConfigurationChanged(newConfig) 123 mHostViews.forEach { 124 it.updateSize(newConfig.orientation) 125 it.reapplyRemoteViews() 126 } 127 synchronized(this) { 128 mLastConfiguration = newConfig 129 mConfigurationChanged?.countDown() 130 } 131 } 132 resetConfigurationChangedLatchnull133 fun resetConfigurationChangedLatch() { 134 synchronized(this) { 135 mConfigurationChanged = CountDownLatch(1) 136 mLastConfiguration = null 137 } 138 } 139 140 // This should not be called from the main thread, so that it does not block 141 // onConfigurationChanged from being called. waitForConfigurationChangenull142 fun waitForConfigurationChange() { 143 val result = mConfigurationChanged?.await(5, TimeUnit.SECONDS)!! 144 require(result) { "Timeout before getting configuration" } 145 } 146 } 147 148 @RequiresApi(26) 149 class TestAppWidgetHost(context: Context, hostId: Int) : AppWidgetHost(context, hostId) { onCreateViewnull150 override fun onCreateView( 151 context: Context, 152 appWidgetId: Int, 153 appWidget: AppWidgetProviderInfo? 154 ): AppWidgetHostView = TestAppWidgetHostView(context) 155 156 override fun onProviderChanged(appWidgetId: Int, appWidget: AppWidgetProviderInfo?) { 157 // In tests, we aren't testing anything specific to how widget behaves on PACKAGE_CHANGED. 158 // In a few SDK versions (http://shortn/_PpxiDuRnvb, http://shortn/_TysXctaGMI), 159 // onProviderChange resets the widget with null value - which happens in middle of test 160 // in-deterministically. For example, in local emulators it doesn't get called sometimes. 161 // So we override this method to prevent reset. 162 // TODO: Make this conditional or find a way to avoid PACKAGE_CHANGED in middle of the test. 163 Log.w(TAG, "Ignoring onProviderChanged for $appWidgetId.") 164 } 165 } 166 167 @RequiresApi(26) 168 class TestAppWidgetHostView(context: Context) : AppWidgetHostView(context) { 169 170 init { 171 // Prevent asynchronous inflation of the App Widget 172 setExecutor(null) 173 layoutDirection = View.LAYOUT_DIRECTION_LOCALE 174 } 175 176 private var mLatch: CountDownLatch? = null 177 var mRemoteViews: RemoteViews? = null 178 private set 179 180 private var mPortraitSize: DpSize = DpSize(0.dp, 0.dp) 181 private var mLandscapeSize: DpSize = DpSize(0.dp, 0.dp) 182 183 /** 184 * Wait for the new remote views to be received. If a remote views was already received, return 185 * immediately. 186 */ waitForRemoteViewsnull187 fun waitForRemoteViews() { 188 synchronized(this) { 189 mRemoteViews?.let { 190 return 191 } 192 mLatch = CountDownLatch(1) 193 } 194 val result = mLatch?.await(30, TimeUnit.SECONDS)!! 195 require(result) { "Timeout before getting RemoteViews" } 196 } 197 updateAppWidgetnull198 override fun updateAppWidget(remoteViews: RemoteViews?) { 199 if (VERBOSE_LOG) { 200 Log.d(RECEIVER_TEST_TAG, "updateAppWidget() called with: $remoteViews") 201 } 202 203 super.updateAppWidget(remoteViews) 204 synchronized(this) { 205 mRemoteViews = remoteViews 206 if (remoteViews != null) { 207 mLatch?.countDown() 208 } 209 } 210 } 211 prepareViewnull212 override fun prepareView(view: View?) { 213 if (VERBOSE_LOG) { 214 Log.d(RECEIVER_TEST_TAG, "prepareView() called with: view = $view") 215 } 216 217 super.prepareView(view) 218 } 219 220 /** Reset the latch used to detect the arrival of a new RemoteViews. */ resetRemoteViewsLatchnull221 fun resetRemoteViewsLatch() { 222 synchronized(this) { 223 mRemoteViews = null 224 mLatch = null 225 } 226 } 227 setSizesnull228 fun setSizes(portraitSize: DpSize, landscapeSize: DpSize) { 229 mPortraitSize = portraitSize 230 mLandscapeSize = landscapeSize 231 updateSize(resources.configuration.orientation) 232 } 233 updateSizenull234 fun updateSize(orientation: Int) { 235 val size = 236 when (orientation) { 237 Configuration.ORIENTATION_LANDSCAPE -> mLandscapeSize 238 Configuration.ORIENTATION_PORTRAIT -> mPortraitSize 239 else -> error("Unknown orientation ${context.resources.configuration.orientation}") 240 } 241 val displayMetrics = resources.displayMetrics 242 val width = size.width.toPixels(displayMetrics) 243 val height = size.height.toPixels(displayMetrics) 244 245 // The widget host applies a default padding that is difficult to remove. Make the outer 246 // host view bigger by the default padding amount, so that the inner view that we care about 247 // matches the provided size. 248 val hostViewPadding = Rect() 249 val testComponent = 250 ComponentName(context.applicationContext, TestGlanceAppWidgetReceiver::class.java) 251 getDefaultPaddingForWidget(context, testComponent, hostViewPadding) 252 val paddedWidth = width + hostViewPadding.left + hostViewPadding.right 253 val paddedHeight = height + hostViewPadding.top + hostViewPadding.bottom 254 255 layoutParams = LayoutParams(paddedWidth, paddedHeight, Gravity.CENTER) 256 requestLayout() 257 } 258 reapplyRemoteViewsnull259 fun reapplyRemoteViews() { 260 mRemoteViews?.let { super.updateAppWidget(it) } 261 } 262 } 263