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