• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2025 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 android.bluetooth.opp
18 
19 import android.Manifest
20 import android.app.KeyguardManager
21 import android.bluetooth.BluetoothManager
22 import android.bluetooth.Host
23 import android.bluetooth.PandoraDevice
24 import android.bluetooth.test_utils.EnableBluetoothRule
25 import android.content.ClipData
26 import android.content.Intent
27 import android.content.Intent.EXTRA_STREAM
28 import android.net.Uri
29 import android.os.Build
30 import android.platform.test.annotations.RequiresFlagsEnabled
31 import android.platform.test.flag.junit.DeviceFlagsValueProvider
32 import androidx.test.filters.SdkSuppress
33 import androidx.test.platform.app.InstrumentationRegistry
34 import androidx.test.uiautomator.By
35 import androidx.test.uiautomator.UiDevice
36 import androidx.test.uiautomator.Until
37 import com.android.bluetooth.flags.Flags
38 import com.android.compatibility.common.util.AdoptShellPermissionsRule
39 import com.google.common.truth.Truth.assertThat
40 import com.google.testing.junit.testparameterinjector.TestParameter
41 import com.google.testing.junit.testparameterinjector.TestParameterInjector
42 import kotlin.time.Duration
43 import kotlin.time.Duration.Companion.milliseconds
44 import kotlinx.coroutines.ExperimentalCoroutinesApi
45 import org.junit.After
46 import org.junit.Before
47 import org.junit.Rule
48 import org.junit.Test
49 import org.junit.runner.RunWith
50 
51 /**
52  * Test cases for sending a file via OPP using a share intent. Uses the Bumble helper app as a
53  * separate package to launch [Intent.ACTION_SEND] or [Intent.ACTION_SEND_MULTIPLE] intents with
54  * content URIs that it may (not) have permissions to access.
55  */
56 @RunWith(TestParameterInjector::class)
57 @ExperimentalCoroutinesApi
58 class OppSendFileTest {
59     val mInstrumentation = InstrumentationRegistry.getInstrumentation()
60     val mContext = mInstrumentation.targetContext
61     val mDevice
62         get() = UiDevice.getInstance(mInstrumentation)
63 
64     val mAdapter
65         get() = mContext.getSystemService(BluetoothManager::class.java).adapter
66 
67     @get:Rule(order = 1) // Cleans up shell permissions, must be run before shell permissions rule
68     val mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule()
69 
70     @get:Rule(order = 2)
71     val mPermissionRule =
72         AdoptShellPermissionsRule(
73             mInstrumentation.uiAutomation,
74             Manifest.permission.BLUETOOTH_PRIVILEGED,
75             Manifest.permission.CREATE_USERS,
76             Manifest.permission.INTERACT_ACROSS_USERS,
77         )
78 
79     @get:Rule(order = 3) val mBumble = PandoraDevice()
80 
81     @get:Rule(order = 4)
82     val mEnableBluetoothRule =
83         EnableBluetoothRule(/* enableTestMode= */ false, /* toggleBluetooth= */ true)
84 
85     val mRemoteDevice
86         get() = mBumble.remoteDevice
87 
88     lateinit var mHost: Host
89 
90     @Before
91     fun setUp() {
92         mHost = Host(mContext)
93         navigateToUnlockedHomeScreen()
94         mAdapter.bondedDevices.forEach(mHost::removeBondAndVerify)
95         mHost.createBondAndVerify(mRemoteDevice)
96     }
97 
98     private fun navigateToUnlockedHomeScreen() {
99         val keyguardManager: KeyguardManager =
100             mContext.getSystemService(KeyguardManager::class.java)
101         if (keyguardManager.isKeyguardLocked) {
102             dismissKeyguard()
103         }
104         mDevice.pressHome()
105     }
106 
107     private fun dismissKeyguard() {
108         val keyguardManager: KeyguardManager =
109             mContext.getSystemService(KeyguardManager::class.java)
110         retryUntil(condition = { !keyguardManager.isKeyguardLocked }) {
111             mDevice.executeShellCommand("wm dismiss-keyguard")
112         }
113     }
114 
115     private fun retryUntil(
116         times: Int = DEFAULT_MAX_RETRIES,
117         delay: Duration = DEFAULT_RETRY_DELAY,
118         condition: () -> Boolean,
119         block: () -> Unit,
120     ) {
121         for (i in 1..times) {
122             block()
123             if (!condition()) {
124                 Thread.sleep(delay.inWholeMilliseconds)
125             }
126             if (condition()) {
127                 break
128             }
129             assertThat(i).isAtMost(times)
130         }
131     }
132 
133     @After
134     fun tearDown() {
135         mRemoteDevice.removeBond()
136         mDevice.pressBack()
137         mDevice.pressHome()
138     }
139 
140     @Test
141     @Throws(Exception::class)
142     @RequiresFlagsEnabled(Flags.FLAG_OPP_CHECK_CONTENT_URI_PERMISSIONS)
143     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
144     fun sendViaBluetoothShare(
145         @TestParameter crossUser: Boolean,
146         @TestParameter hasPermission: Boolean,
147         @TestParameter hasGrant: Boolean,
148     ) =
149         contentUriTest("content://${authority(crossUser, hasPermission)}/test_image.png") { uris ->
150             val intent = createSendIntent(uris.single(), hasGrant)
151 
152             startIntentAndAssertDevicePicker(
153                 intent,
154                 shouldSucceed = !crossUser && (hasPermission || hasGrant),
155             )
156         }
157 
158     private fun createSendIntent(uri: Uri, grantUriPermission: Boolean): Intent =
159         Intent(Intent.ACTION_MAIN)
160             .putExtra(EXTRA_STREAM, uri)
161             .setFlags(flags(grantUriPermission))
162             .setClassName(HELPER_PACKAGE, SEND_ACTIVITY)
163             .setData(uri)
164 
165     @Test
166     @Throws(Exception::class)
167     @RequiresFlagsEnabled(Flags.FLAG_OPP_CHECK_CONTENT_URI_PERMISSIONS)
168     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
169     fun sendMultipleViaBluetoothShare(
170         @TestParameter crossUser: Boolean,
171         @TestParameter hasPermission: Boolean,
172         @TestParameter hasGrant: Boolean,
173     ) =
174         contentUriTest(
175             "content://${authority(crossUser, hasPermission)}/test_image.png",
176             "content://${authority(crossUser, hasPermission)}/test_image2.png",
177         ) { uris ->
178             val intent = createSendMultipleIntent(uris, hasGrant)
179 
180             startIntentAndAssertDevicePicker(
181                 intent,
182                 shouldSucceed = !crossUser && (hasPermission || hasGrant),
183             )
184         }
185 
186     @Test
187     @Throws(Exception::class)
188     @RequiresFlagsEnabled(Flags.FLAG_OPP_CHECK_CONTENT_URI_PERMISSIONS)
189     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
190     fun sendMultipleViaBluetoothShare_oneAlwaysAllowed(
191         @TestParameter crossUser: Boolean,
192         @TestParameter hasPermission: Boolean,
193         @TestParameter hasGrant: Boolean,
194     ) =
195         contentUriTest(
196             "content://${authority(crossUser, hasPermission)}/test_image.png",
197             "content://$PERMITTED_AUTHORITY/test_image2.png",
198         ) { uris ->
199             val intent = createSendMultipleIntent(uris, hasGrant)
200 
201             startIntentAndAssertDevicePicker(intent, shouldSucceed = true)
202         }
203 
204     private fun createSendMultipleIntent(
205         uris: ArrayList<Uri>,
206         grantUriPermission: Boolean,
207     ): Intent =
208         Intent(Intent.ACTION_MAIN)
209             .putExtra(EXTRA_STREAM, uris)
210             .setType(MIME_TYPE_IMAGE)
211             .setFlags(flags(grantUriPermission))
212             .setClassName(HELPER_PACKAGE, SEND_MULTIPLE_ACTIVITY)
213             .apply { clipData = uris.toClipData() }
214 
215     private fun ArrayList<Uri>.toClipData(): ClipData {
216         val items = map(ClipData::Item)
217         return ClipData("URIs", arrayOf(MIME_TYPE_IMAGE), items.first()).apply {
218             items.drop(1).forEach(::addItem)
219         }
220     }
221 
222     private fun startIntentAndAssertDevicePicker(intent: Intent, shouldSucceed: Boolean) {
223         mContext.startActivity(intent)
224 
225         val devicePickerText =
226             mDevice.wait(Until.findObject(By.text("Available devices")), TIMEOUT_MS)
227         if (shouldSucceed) {
228             assertThat(devicePickerText).isNotNull()
229 
230             val name = mRemoteDevice.name
231 
232             val shareToBumbleButton =
233                 mDevice.wait(Until.findObject(By.textStartsWith(name)), ASSERT_TIMEOUT_MS)
234 
235             assertThat(shareToBumbleButton).isNotNull()
236         } else {
237             assertThat(devicePickerText).isNull()
238         }
239     }
240 
241     private fun authority(crossUser: Boolean, hasPermission: Boolean): String {
242         val hostPart =
243             if (hasPermission) {
244                 PERMITTED_AUTHORITY
245             } else {
246                 RESTRICTED_AUTHORITY
247             }
248         return if (crossUser) {
249             "$OTHER_USER_ID@$hostPart"
250         } else {
251             hostPart
252         }
253     }
254 
255     private fun flags(grantUriPermission: Boolean) =
256         Intent.FLAG_ACTIVITY_NEW_TASK or
257             if (grantUriPermission) {
258                 Intent.FLAG_GRANT_READ_URI_PERMISSION
259             } else {
260                 0
261             }
262 
263     /** Provides parsed content URIs to the test block. Cleans up URI grants before and after. */
264     private fun contentUriTest(vararg uriString: String, test: (uris: ArrayList<Uri>) -> Unit) {
265         val uris = uriString.map(Uri::parse).let(::ArrayList)
266         AutoCloseable { uris.revokePermissions() }
267             .use {
268                 uris.revokePermissions()
269                 test(uris)
270             }
271     }
272 
273     private fun List<Uri>.revokePermissions() = forEach {
274         mContext.revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
275     }
276 
277     companion object {
278         private const val MIME_TYPE_IMAGE = "image/*"
279         private const val TIMEOUT_MS = 5000L
280         private const val ASSERT_TIMEOUT_MS = 10000L
281         private const val PERMITTED_AUTHORITY = "android.bluetooth.opp"
282         private const val RESTRICTED_AUTHORITY = "android.bluetooth.opp.restricted"
283         private const val HELPER_PACKAGE = "android.bluetooth.helper"
284         private const val SEND_ACTIVITY =
285             "android.bluetooth.helper.opp.SendToBluetoothShareActivity"
286         private const val SEND_MULTIPLE_ACTIVITY =
287             "android.bluetooth.helper.opp.SendMultipleToBluetoothShareActivity"
288         private const val OTHER_USER_ID = 10
289         private const val DEFAULT_MAX_RETRIES = 8
290         private val DEFAULT_RETRY_DELAY = 250.milliseconds
291     }
292 }
293