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