1 /*
2  * Copyright 2024 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 com.android.photopicker.core.glide
18 
19 import android.content.ContentProvider
20 import android.content.ContentResolver
21 import android.graphics.Point
22 import android.graphics.drawable.Drawable
23 import android.net.Uri
24 import android.os.Bundle
25 import android.os.CancellationSignal
26 import android.provider.CloudMediaProviderContract
27 import androidx.compose.ui.test.junit4.createComposeRule
28 import androidx.test.platform.app.InstrumentationRegistry
29 import com.android.photopicker.R
30 import com.android.photopicker.core.ApplicationModule
31 import com.android.photopicker.core.ApplicationOwned
32 import com.android.photopicker.test.utils.GlideLoadableIdlingResource
33 import com.android.photopicker.test.utils.MockContentProviderWrapper
34 import com.android.photopicker.tests.utils.mockito.capture
35 import com.android.photopicker.tests.utils.mockito.whenever
36 import com.bumptech.glide.Glide
37 import com.bumptech.glide.RequestBuilder
38 import com.bumptech.glide.load.DataSource
39 import com.bumptech.glide.load.engine.DiskCacheStrategy
40 import com.bumptech.glide.load.engine.GlideException
41 import com.bumptech.glide.request.RequestListener
42 import com.bumptech.glide.request.target.Target
43 import com.bumptech.glide.signature.ObjectKey
44 import com.google.common.truth.Truth.assertThat
45 import dagger.hilt.android.testing.BindValue
46 import dagger.hilt.android.testing.HiltAndroidRule
47 import dagger.hilt.android.testing.HiltAndroidTest
48 import dagger.hilt.android.testing.UninstallModules
49 import org.junit.After
50 import org.junit.Before
51 import org.junit.Rule
52 import org.junit.Test
53 import org.mockito.ArgumentCaptor
54 import org.mockito.Captor
55 import org.mockito.Mock
56 import org.mockito.Mockito.any
57 import org.mockito.Mockito.reset
58 import org.mockito.Mockito.verify
59 import org.mockito.MockitoAnnotations
60 
61 /**
62  * Unit tests for the main [loadMedia] composable.
63  *
64  * This class does not test all of Glide's individual internals, but rather tests the start and end
65  * points of the Glide pipeline. All of the ContentResolver calls are intercepted and verified to
66  * ensure the pipeline is passing the correct data when fetching bytes from a [ContentResolver].
67  *
68  * These tests do not guarantee loading success; Glide's internals can still fail, and any
69  * exceptions that are thrown there are not propagated to the test thread. This just ensures that
70  * the entrypoint into Glide produces the expected exit values to the ContentProvider.
71  *
72  * This test will replace the bindings in [ApplicationModule], so the module is uninstalled.
73  */
74 @UninstallModules(ApplicationModule::class)
75 @HiltAndroidTest
76 class LoadMediaTest {
77 
78     /** Hilt's rule needs to come first to ensure the DI container is setup for the test. */
79     @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this)
80     @get:Rule(order = 1) val composeTestRule = createComposeRule()
81 
82     private val glideIdlingResource: GlideLoadableIdlingResource = GlideLoadableIdlingResource()
83     private lateinit var provider: MockContentProviderWrapper
84 
85     /** Replace the injected ContentResolver binding in [ApplicationModule] with this test value. */
86     @BindValue @ApplicationOwned lateinit var contentResolver: ContentResolver
87 
88     @Mock lateinit var mockContentProvider: ContentProvider
89     @Captor lateinit var uri: ArgumentCaptor<Uri>
90     @Captor lateinit var mimeType: ArgumentCaptor<String>
91     @Captor lateinit var options: ArgumentCaptor<Bundle>
92 
93     /** Simple implementation of a GlideLoadable */
94     private val loadable =
95         object : GlideLoadable {
96 
getSignaturenull97             override fun getSignature(resolution: Resolution): ObjectKey {
98                 return ObjectKey("${getLoadableUri()}_$resolution")
99             }
100 
getLoadableUrinull101             override fun getLoadableUri(): Uri {
102                 return Uri.EMPTY.buildUpon()
103                     .apply {
104                         scheme("content")
105                         authority(MockContentProviderWrapper.AUTHORITY)
106                         path("${CloudMediaProviderContract.URI_PATH_MEDIA}/1234")
107                     }
108                     .build()
109             }
110 
getDataSourcenull111             override fun getDataSource(): DataSource {
112                 return DataSource.LOCAL
113             }
114 
getTimestampnull115             override fun getTimestamp(): Long {
116                 return 100L
117             }
118         }
119 
120     @Before
setupnull121     fun setup() {
122         MockitoAnnotations.initMocks(this)
123 
124         provider = MockContentProviderWrapper(mockContentProvider)
125         contentResolver = ContentResolver.wrap(provider)
126 
127         /** Make compose aware of the async Glide pipeline */
128         composeTestRule.registerIdlingResource(glideIdlingResource)
129     }
130 
131     @After()
teardownnull132     fun teardown() {
133         composeTestRule.unregisterIdlingResource(glideIdlingResource)
134         glideIdlingResource.reset()
135 
136         // It is important to tearDown glide after every test to ensure it picks up the updated
137         // mocks from Hilt and mocks aren't leaked between tests.
138         Glide.tearDown()
139     }
140 
141     /** Ensures that a [GlideLoadable] can be loaded via the [loadMedia] composable using Glide. */
142     @Test
testLoadMediaGenericThumbnailResolutionnull143     fun testLoadMediaGenericThumbnailResolution() {
144 
145         // Return a resource png so the request is actually backed by something.
146         whenever(mockContentProvider.openTypedAssetFile(any(), any(), any(), any())) {
147             InstrumentationRegistry.getInstrumentation()
148                 .getContext()
149                 .getResources()
150                 .openRawResourceFd(R.drawable.android)
151         }
152 
153         composeTestRule.setContent {
154             loadMedia(
155                 media = loadable,
156                 resolution = Resolution.THUMBNAIL,
157                 requestBuilderTransformation = ::setupRequestListener,
158             )
159         }
160 
161         // Wait for the [GlideLoadableIdlingResource] to indicate the glide loading
162         // pipeline is idle.
163         composeTestRule.waitForIdle()
164 
165         verify(mockContentProvider)
166             .openTypedAssetFile(
167                 capture(uri),
168                 capture(mimeType),
169                 capture(options),
170                 any(CancellationSignal::class.java)
171             )
172 
173         assertThat(uri.getValue()).isEqualTo(loadable.getLoadableUri())
174 
175         // Glide can only load images, so ensure we're requesting the correct mimeType.
176         assertThat(mimeType.getValue()).isEqualTo(DEFAULT_IMAGE_MIME_TYPE)
177 
178         // Ensure the CloudProvider is being told to return a preview thumbnail, in case the
179         // loadable is a video.
180         assertThat(
181                 options.getValue().getBoolean(CloudMediaProviderContract.EXTRA_PREVIEW_THUMBNAIL)
182             )
183             .isTrue()
184 
185         // This is a request for thumbnail, this needs to be set to get cached thumbnails from
186         // MediaProvider.
187         assertThat(options.getValue().getBoolean(CloudMediaProviderContract.EXTRA_MEDIASTORE_THUMB))
188             .isTrue()
189 
190         // Ensure the object is a Point, but the actual size doesn't matter in this context.
191         assertThat(options.getValue().getParcelable(ContentResolver.EXTRA_SIZE, Point::class.java))
192             .isNotNull()
193     }
194 
195     /** Ensures that a [GlideLoadable] can be loaded via the [loadMedia] composable using Glide. */
196     @Test
testLoadMediaGenericFullResolutionnull197     fun testLoadMediaGenericFullResolution() {
198 
199         // Return a resource png so the request is actually backed by something.
200         whenever(mockContentProvider.openTypedAssetFile(any(), any(), any(), any())) {
201             InstrumentationRegistry.getInstrumentation()
202                 .getContext()
203                 .getResources()
204                 .openRawResourceFd(R.drawable.android)
205         }
206 
207         composeTestRule.setContent {
208             loadMedia(
209                 media = loadable,
210                 resolution = Resolution.FULL,
211                 requestBuilderTransformation = ::setupRequestListener,
212             )
213         }
214 
215         // Wait for the [GlideLoadableIdlingResource] to indicate the glide loading
216         // pipeline is idle.
217         composeTestRule.waitForIdle()
218 
219         verify(mockContentProvider)
220             .openTypedAssetFile(
221                 capture(uri),
222                 capture(mimeType),
223                 capture(options),
224                 any(CancellationSignal::class.java)
225             )
226 
227         assertThat(uri.getValue()).isEqualTo(loadable.getLoadableUri())
228 
229         // Glide can only load images, so ensure we're requesting the correct mimeType.
230         assertThat(mimeType.getValue()).isEqualTo(DEFAULT_IMAGE_MIME_TYPE)
231 
232         // Ensure the CloudProvider is being told to return a preview thumbnail, in case the
233         // loadable is a video.
234         assertThat(
235                 options.getValue().getBoolean(CloudMediaProviderContract.EXTRA_PREVIEW_THUMBNAIL)
236             )
237             .isTrue()
238 
239         // This should not be in the bundle, but the default value returned will be false.
240         assertThat(
241                 options.getValue().containsKey(CloudMediaProviderContract.EXTRA_MEDIASTORE_THUMB)
242             )
243             .isFalse()
244         assertThat(options.getValue().getBoolean(CloudMediaProviderContract.EXTRA_MEDIASTORE_THUMB))
245             .isFalse()
246 
247         // Ensure the object is a Point, but the actual size doesn't matter in this context.
248         assertThat(options.getValue().getParcelable(ContentResolver.EXTRA_SIZE, Point::class.java))
249             .isNotNull()
250     }
251 
252     /**
253      * This uses glides internal [RequestListener] to add test hooks into the Glide loading cycle.
254      * This registers a listener which will notify the [GlideLoadableIdlingResource] when the
255      * configured image load has either completed or failed.
256      *
257      * Note: This doesn't actually care if the load fails or succeeds, it just unblocks the idling
258      * resource so the test can proceed. Any exceptions that are thrown inside of the listener will
259      * get swallowed by Glide's internals.
260      */
setupRequestListenernull261     private fun setupRequestListener(
262         media: GlideLoadable,
263         resolution: Resolution,
264         builder: RequestBuilder<Drawable>
265     ): RequestBuilder<Drawable> {
266 
267         glideIdlingResource.loadStarted()
268 
269         val listener =
270             object : RequestListener<Drawable> {
271 
272                 override fun onLoadFailed(
273                     ex: GlideException?,
274                     model: Any?,
275                     target: Target<Drawable>,
276                     isFirstResource: Boolean
277                 ): Boolean {
278                     glideIdlingResource.loadFinished()
279                     // Return false to indicate the target hasn't been modified by the listener.
280                     return false
281                 }
282 
283                 override fun onResourceReady(
284                     resource: Drawable,
285                     model: Any,
286                     target: Target<Drawable>,
287                     datasource: DataSource,
288                     isFirstResource: Boolean
289                 ): Boolean {
290                     glideIdlingResource.loadFinished()
291                     // Return false to indicate the target hasn't been modified by the listener.
292                     return false
293                 }
294             }
295 
296         return builder
297             .listener(listener)
298             .set(RESOLUTION_REQUESTED, resolution)
299             // Ensure that for tests we skip all possible caching options
300             .diskCacheStrategy(DiskCacheStrategy.NONE)
301             .skipMemoryCache(true)
302             .signature(media.getSignature(resolution))
303     }
304 }
305