| /* |
| * Copyright (C) 2016 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package android.providerui.cts; |
| |
| import static android.provider.cts.ProviderTestUtils.resolveVolumeName; |
| |
| import static org.junit.Assert.assertArrayEquals; |
| import static org.junit.Assert.assertEquals; |
| import static org.junit.Assert.assertNotNull; |
| import static org.junit.Assert.assertTrue; |
| import static org.junit.Assert.fail; |
| |
| import android.app.Activity; |
| import android.app.Instrumentation; |
| import android.app.UiAutomation; |
| import android.content.ContentResolver; |
| import android.content.ContentUris; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.UriPermission; |
| import android.content.pm.PackageManager; |
| import android.content.pm.ResolveInfo; |
| import android.database.Cursor; |
| import android.net.Uri; |
| import android.os.Environment; |
| import android.os.FileUtils; |
| import android.os.ParcelFileDescriptor; |
| import android.os.storage.StorageManager; |
| import android.os.storage.StorageVolume; |
| import android.provider.DocumentsContract; |
| import android.provider.MediaStore; |
| import android.provider.cts.ProviderTestUtils; |
| import android.providerui.cts.GetResultActivity.Result; |
| import android.support.test.uiautomator.By; |
| import android.support.test.uiautomator.BySelector; |
| import android.support.test.uiautomator.UiDevice; |
| import android.support.test.uiautomator.UiObject; |
| import android.support.test.uiautomator.UiObject2; |
| import android.support.test.uiautomator.UiObjectNotFoundException; |
| import android.support.test.uiautomator.UiSelector; |
| import android.support.test.uiautomator.Until; |
| import android.system.Os; |
| import android.text.format.DateUtils; |
| import android.util.Log; |
| import android.util.Pair; |
| |
| import androidx.test.InstrumentationRegistry; |
| |
| import org.junit.After; |
| import org.junit.Before; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.junit.runners.Parameterized; |
| import org.junit.runners.Parameterized.Parameter; |
| import org.junit.runners.Parameterized.Parameters; |
| |
| import java.io.BufferedReader; |
| import java.io.ByteArrayInputStream; |
| import java.io.File; |
| import java.io.FileInputStream; |
| import java.io.FileNotFoundException; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.InputStreamReader; |
| import java.io.OutputStream; |
| import java.nio.charset.StandardCharsets; |
| |
| @RunWith(Parameterized.class) |
| public class MediaStoreUiTest { |
| private static final String TAG = "MediaStoreUiTest"; |
| |
| private static final int REQUEST_CODE = 42; |
| private static final long TIMEOUT_MILLIS = 30 * DateUtils.SECOND_IN_MILLIS; |
| private static final String MEDIA_DOCUMENTS_PROVIDER_AUTHORITY = |
| "com.android.providers.media.documents"; |
| |
| private Instrumentation mInstrumentation; |
| private Context mContext; |
| private UiDevice mDevice; |
| private GetResultActivity mActivity; |
| |
| private File mFile; |
| private Uri mMediaStoreUri; |
| private String mTargetPackageName; |
| private String mDocumentsUiPackageId; |
| |
| @Parameter(0) |
| public String mVolumeName; |
| |
| @Parameters |
| public static Iterable<? extends Object> data() { |
| return ProviderTestUtils.getSharedVolumeNames(); |
| } |
| |
| @Before |
| public void setUp() throws Exception { |
| mInstrumentation = InstrumentationRegistry.getInstrumentation(); |
| mContext = InstrumentationRegistry.getTargetContext(); |
| mDevice = UiDevice.getInstance(mInstrumentation); |
| final PackageManager pm = mContext.getPackageManager(); |
| final Intent intent2 = new Intent(Intent.ACTION_OPEN_DOCUMENT); |
| intent2.addCategory(Intent.CATEGORY_OPENABLE); |
| intent2.setType("*/*"); |
| final ResolveInfo ri = pm.resolveActivity(intent2, 0); |
| mDocumentsUiPackageId = ri.activityInfo.packageName; |
| |
| final Intent intent = new Intent(mContext, GetResultActivity.class); |
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); |
| mActivity = (GetResultActivity) mInstrumentation.startActivitySync(intent); |
| mInstrumentation.waitForIdleSync(); |
| mActivity.clearResult(); |
| mDevice.wakeUp(); |
| } |
| |
| @After |
| public void tearDown() throws Exception { |
| if (mFile != null) { |
| mFile.delete(); |
| } |
| |
| if (mActivity != null) { |
| final ContentResolver resolver = mActivity.getContentResolver(); |
| for (UriPermission permission : resolver.getPersistedUriPermissions()) { |
| mActivity.revokeUriPermission( |
| permission.getUri(), |
| Intent.FLAG_GRANT_READ_URI_PERMISSION |
| | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); |
| } |
| mActivity.finish(); |
| } |
| } |
| |
| @Test |
| public void testGetDocumentUri() throws Exception { |
| if (!supportsHardware()) return; |
| |
| prepareFile(); |
| clearDocumentsUi(); |
| |
| final Uri treeUri = acquireAccess(mFile, Environment.DIRECTORY_DOCUMENTS); |
| assertNotNull(treeUri); |
| |
| final Uri docUri = MediaStore.getDocumentUri(mActivity, mMediaStoreUri); |
| assertNotNull(docUri); |
| |
| final ContentResolver resolver = mActivity.getContentResolver(); |
| |
| // Test reading |
| final byte[] expected = "TEST".getBytes(); |
| final byte[] actual = new byte[4]; |
| try (ParcelFileDescriptor fd = resolver.openFileDescriptor(docUri, "r")) { |
| Os.read(fd.getFileDescriptor(), actual, 0, actual.length); |
| assertArrayEquals(expected, actual); |
| } |
| |
| // Test writing |
| try (ParcelFileDescriptor fd = resolver.openFileDescriptor(docUri, "wt")) { |
| Os.write(fd.getFileDescriptor(), expected, 0, expected.length); |
| } |
| } |
| |
| @Test |
| public void testGetDocumentUri_throwsWithoutPermission() throws Exception { |
| if (!supportsHardware()) return; |
| |
| prepareFile(); |
| clearDocumentsUi(); |
| |
| try { |
| MediaStore.getDocumentUri(mActivity, mMediaStoreUri); |
| fail("Expecting SecurityException."); |
| } catch (SecurityException e) { |
| // Expected |
| } |
| } |
| |
| @Test |
| public void testGetDocumentUri_symmetry_externalStorageProvider() throws Exception { |
| if (!supportsHardware()) return; |
| |
| prepareFile(); |
| clearDocumentsUi(); |
| |
| final Uri treeUri = acquireAccess(mFile, Environment.DIRECTORY_DOCUMENTS); |
| Log.v(TAG, "Tree " + treeUri); |
| assertNotNull(treeUri); |
| |
| final Uri docUri = MediaStore.getDocumentUri(mActivity, mMediaStoreUri); |
| Log.v(TAG, "Document " + docUri); |
| assertNotNull(docUri); |
| |
| final Uri mediaUri = MediaStore.getMediaUri(mActivity, docUri); |
| Log.v(TAG, "Media " + mediaUri); |
| assertNotNull(mediaUri); |
| |
| assertEquals(mMediaStoreUri, mediaUri); |
| assertAccessToMediaUri(mediaUri, mFile); |
| } |
| |
| @Test |
| public void testGetMediaUriAccess_mediaDocumentsProvider() throws Exception { |
| if (!supportsHardware()) return; |
| |
| prepareFile("TEST"); |
| clearDocumentsUi(); |
| final Intent intent = new Intent(); |
| intent.setAction(Intent.ACTION_OPEN_DOCUMENT); |
| intent.addCategory(Intent.CATEGORY_OPENABLE); |
| intent.setType("*/*"); |
| mActivity.startActivityForResult(intent, REQUEST_CODE); |
| mDevice.waitForIdle(); |
| |
| findDocument(mFile.getName()).click(); |
| final Result result = mActivity.getResult(); |
| final Uri uri = result.data.getData(); |
| assertEquals(MEDIA_DOCUMENTS_PROVIDER_AUTHORITY, uri.getAuthority()); |
| final Uri mediaUri = MediaStore.getMediaUri(mActivity, uri); |
| |
| assertAccessToMediaUri(mediaUri, mFile); |
| } |
| |
| @Test |
| public void testOpenFile_onMediaDocumentsProvider_success() throws Exception { |
| if (!supportsHardware()) return; |
| |
| final String rawText = "TEST"; |
| // Stage a text file which contains raw text "TEST" |
| prepareFile(rawText); |
| clearDocumentsUi(); |
| final Intent intent = new Intent(); |
| intent.setAction(Intent.ACTION_OPEN_DOCUMENT); |
| intent.addCategory(Intent.CATEGORY_OPENABLE); |
| intent.setType("*/*"); |
| mActivity.startActivityForResult(intent, REQUEST_CODE); |
| mDevice.waitForIdle(); |
| |
| findDocument(mFile.getName()).click(); |
| final Result result = mActivity.getResult(); |
| final Uri uri = result.data.getData(); |
| assertEquals(MEDIA_DOCUMENTS_PROVIDER_AUTHORITY, uri.getAuthority()); |
| |
| // Test reading |
| final byte[] expected = rawText.getBytes(); |
| final byte[] actual = new byte[4]; |
| try (ParcelFileDescriptor fd = mContext.getContentResolver() |
| .openFileDescriptor(uri, "r")) { |
| Os.read(fd.getFileDescriptor(), actual, 0, actual.length); |
| assertArrayEquals(expected, actual); |
| } |
| |
| // Test write and read after it |
| final byte[] writtenText = "Hello World".getBytes(); |
| final byte[] readText = new byte[11]; |
| try (ParcelFileDescriptor fd = mContext.getContentResolver() |
| .openFileDescriptor(uri, "wt")) { |
| Os.write(fd.getFileDescriptor(), writtenText, 0, writtenText.length); |
| } |
| try (ParcelFileDescriptor fd = mContext.getContentResolver() |
| .openFileDescriptor(uri, "r")) { |
| Os.read(fd.getFileDescriptor(), readText, 0, readText.length); |
| assertArrayEquals(writtenText, readText); |
| } |
| } |
| |
| @Test |
| public void testOpenFile_onMediaDocumentsProvider_failsWithoutAccess() throws Exception { |
| if (!supportsHardware()) return; |
| |
| clearDocumentsUi(); |
| final Intent intent = new Intent(); |
| intent.setAction(Intent.ACTION_OPEN_DOCUMENT); |
| intent.addCategory(Intent.CATEGORY_OPENABLE); |
| intent.setType("*/*"); |
| mActivity.startActivityForResult(intent, REQUEST_CODE); |
| mDevice.waitForIdle(); |
| |
| String rawText = "TEST"; |
| // Read and write grants will be provided to the file associated with this pair. |
| // Stages a text file which contains raw text "TEST" |
| Pair<Uri, File> uriFilePairWithGrants = prepareFileAndFetchDetails(rawText); |
| // Read and write grants will not be provided to the file associated with this pair |
| // Stages a text file which contains raw text "TEST" |
| Pair<Uri, File> uriFilePairWithoutGrants = prepareFileAndFetchDetails(rawText); |
| // Get access grants |
| findDocument(uriFilePairWithGrants.second.getName()).click(); |
| final Result result = mActivity.getResult(); |
| final Uri docUriOfFileWithAccess = result.data.getData(); |
| // Creating doc URI for file by string replacement |
| Uri docUriOfFileWithoutAccess = Uri.parse(docUriOfFileWithAccess.toSafeString().replaceAll( |
| String.valueOf(ContentUris.parseId(uriFilePairWithGrants.first)), |
| String.valueOf(ContentUris.parseId(uriFilePairWithoutGrants.first)))); |
| |
| try { |
| assertEquals(MEDIA_DOCUMENTS_PROVIDER_AUTHORITY, docUriOfFileWithAccess.getAuthority()); |
| assertEquals(MEDIA_DOCUMENTS_PROVIDER_AUTHORITY, |
| docUriOfFileWithoutAccess.getAuthority()); |
| // Test reading |
| try (ParcelFileDescriptor fd = mContext.getContentResolver().openFileDescriptor( |
| docUriOfFileWithoutAccess, "r")) { |
| fail("Expecting security exception as file does not have read grants which " |
| + "are provided through ACTION_OPEN_DOCUMENT intent."); |
| } catch (SecurityException expected) { |
| // Expected security exception as file does not have read grants |
| } |
| // Test writing |
| try (ParcelFileDescriptor fd = mContext.getContentResolver().openFileDescriptor( |
| docUriOfFileWithoutAccess, "wt")) { |
| fail("Expecting security exception as file does not have write grants which " |
| + "are provided through ACTION_OPEN_DOCUMENT intent."); |
| } catch (SecurityException expected) { |
| // Expected security exception as file does not have write grants |
| } |
| } finally { |
| // Deleting files |
| uriFilePairWithGrants.second.delete(); |
| uriFilePairWithoutGrants.second.delete(); |
| } |
| } |
| |
| private void assertAccessToMediaUri(Uri mediaUri, File file) { |
| final String[] projection = {MediaStore.MediaColumns.DISPLAY_NAME}; |
| try (Cursor c = mContext.getContentResolver().query( |
| mediaUri, projection, null, null, null)) { |
| assertTrue(c.moveToFirst()); |
| assertEquals(file.getName(), c.getString(0)); |
| } |
| } |
| |
| /** |
| * Clears the DocumentsUI package data. |
| */ |
| protected void clearDocumentsUi() throws Exception { |
| executeShellCommand("pm clear " + getDocumentsUiPackageId()); |
| } |
| |
| private UiObject findDocument(String label) throws UiObjectNotFoundException { |
| final UiSelector docList = new UiSelector().resourceId(getDocumentsUiPackageId() |
| + ":id/dir_list"); |
| |
| // Wait for the first list item to appear |
| assertTrue("First list item", |
| new UiObject(docList.childSelector(new UiSelector())) |
| .waitForExists(TIMEOUT_MILLIS)); |
| |
| try { |
| //Enforce to set the list mode |
| //Because UiScrollable can't reach the real bottom (when WEB_LINKABLE_FILE item) |
| // in grid mode when screen landscape mode |
| new UiObject(new UiSelector().resourceId(getDocumentsUiPackageId() |
| + ":id/sub_menu_list")).click(); |
| mDevice.waitForIdle(); |
| }catch (UiObjectNotFoundException e){ |
| //do nothing, already be in list mode. |
| } |
| |
| // Repeat swipe gesture to find our item |
| // (UiScrollable#scrollIntoView does not seem to work well with SwipeRefreshLayout) |
| UiObject targetObject = new UiObject(docList.childSelector(new UiSelector().text(label))); |
| UiObject saveButton = findSaveButton(); |
| int stepLimit = 10; |
| while (stepLimit-- > 0) { |
| if (targetObject.exists()) { |
| boolean targetObjectFullyVisible = !saveButton.exists() |
| || targetObject.getVisibleBounds().bottom |
| <= saveButton.getVisibleBounds().top; |
| if (targetObjectFullyVisible) { |
| break; |
| } |
| } |
| |
| mDevice.swipe(/* startX= */ mDevice.getDisplayWidth() / 2, |
| /* startY= */ mDevice.getDisplayHeight() / 2, |
| /* endX= */ mDevice.getDisplayWidth() / 2, |
| /* endY= */ 0, |
| /* steps= */ 40); |
| } |
| return targetObject; |
| } |
| |
| private UiObject findSaveButton() throws UiObjectNotFoundException { |
| return new UiObject(new UiSelector().resourceId( |
| getDocumentsUiPackageId() + ":id/container_save") |
| .childSelector(new UiSelector().resourceId("android:id/button1"))); |
| } |
| |
| private String getDocumentsUiPackageId() { |
| return mDocumentsUiPackageId; |
| } |
| |
| private boolean supportsHardware() { |
| final PackageManager pm = mContext.getPackageManager(); |
| return !pm.hasSystemFeature("android.hardware.type.television") |
| && !pm.hasSystemFeature("android.hardware.type.watch") |
| && !pm.hasSystemFeature("android.hardware.type.automotive"); |
| } |
| |
| public File getVolumePath(String volumeName) { |
| return mContext.getSystemService(StorageManager.class) |
| .getStorageVolume(MediaStore.Files.getContentUri(volumeName)).getDirectory(); |
| } |
| |
| private void prepareFile() throws Exception { |
| final File dir = new File(getVolumePath(resolveVolumeName(mVolumeName)), |
| Environment.DIRECTORY_DOCUMENTS); |
| final File file = new File(dir, "cts" + System.nanoTime() + ".txt"); |
| |
| mFile = stageFile(R.raw.text, file); |
| mMediaStoreUri = MediaStore.scanFile(mContext.getContentResolver(), mFile); |
| |
| Log.v(TAG, "Staged " + mFile + " as " + mMediaStoreUri); |
| } |
| |
| private void prepareFile(String rawText) throws Exception { |
| final File dir = new File(getVolumePath(resolveVolumeName(mVolumeName)), |
| Environment.DIRECTORY_DOCUMENTS); |
| final File file = new File(dir, "cts" + System.nanoTime() + ".txt"); |
| |
| mFile = stageFileWithRawText(rawText, file); |
| mMediaStoreUri = MediaStore.scanFile(mContext.getContentResolver(), mFile); |
| |
| Log.v(TAG, "Staged " + mFile + " as " + mMediaStoreUri); |
| } |
| |
| private Pair<Uri, File> prepareFileAndFetchDetails(String rawText) throws Exception { |
| final File dir = new File(getVolumePath(resolveVolumeName(mVolumeName)), |
| Environment.DIRECTORY_DOCUMENTS); |
| final File file = new File(dir, "cts" + System.nanoTime() + ".txt"); |
| |
| File stagedFile = stageFileWithRawText(rawText, file); |
| |
| Uri uri = MediaStore.scanFile(mContext.getContentResolver(), stagedFile); |
| Log.v(TAG, "Staged " + stagedFile + " as " + uri); |
| return Pair.create(uri, stagedFile); |
| } |
| |
| private void assertToolbarTitleEquals(String targetPackageName, String label) |
| throws UiObjectNotFoundException { |
| final UiSelector toolbarUiSelector = new UiSelector().resourceId( |
| targetPackageName + ":id/toolbar"); |
| final UiSelector titleTextSelector = new UiSelector().className( |
| "android.widget.TextView").text(label); |
| final UiObject title = new UiObject(toolbarUiSelector.childSelector(titleTextSelector)); |
| |
| assertTrue(title.waitForExists(TIMEOUT_MILLIS)); |
| } |
| |
| private Uri acquireAccess(File file, String directoryName) throws Exception { |
| StorageManager storageManager = |
| (StorageManager) mActivity.getSystemService(Context.STORAGE_SERVICE); |
| |
| // Request access from DocumentsUI |
| final StorageVolume volume = storageManager.getStorageVolume(file); |
| final Intent intent = volume.createOpenDocumentTreeIntent(); |
| |
| // launch the directory directly to avoid unexpected UiObject not found issue |
| final Uri rootUri = intent.getParcelableExtra(DocumentsContract.EXTRA_INITIAL_URI); |
| final String rootId = DocumentsContract.getRootId(rootUri); |
| final String documentId = rootId + ":" + directoryName; |
| intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, |
| DocumentsContract.buildDocumentUri(rootUri.getAuthority(), documentId)); |
| mActivity.startActivityForResult(intent, REQUEST_CODE); |
| |
| if (mTargetPackageName == null) { |
| mTargetPackageName = getTargetPackageName(mActivity); |
| } |
| mDevice.waitForIdle(); |
| assertToolbarTitleEquals(mTargetPackageName, directoryName); |
| |
| // Granting the access |
| BySelector buttonPanelSelector = By.pkg(mTargetPackageName) |
| .res(mTargetPackageName + ":id/container_save"); |
| mDevice.wait(Until.hasObject(buttonPanelSelector), TIMEOUT_MILLIS); |
| final UiObject2 buttonPanel = mDevice.findObject(buttonPanelSelector); |
| final UiObject2 allowButton = buttonPanel.findObject(By.res("android:id/button1")); |
| allowButton.click(); |
| mDevice.waitForIdle(); |
| |
| // Granting the access by click "allow" in confirm dialog |
| final BySelector dialogButtonPanelSelector = By.pkg(mTargetPackageName) |
| .res(mTargetPackageName + ":id/buttonPanel"); |
| mDevice.wait(Until.hasObject(dialogButtonPanelSelector), TIMEOUT_MILLIS); |
| final UiObject2 positiveButton = mDevice.findObject(dialogButtonPanelSelector) |
| .findObject(By.res("android:id/button1")); |
| positiveButton.click(); |
| mDevice.waitForIdle(); |
| |
| // Check granting result and take persistent permission |
| final Result result = mActivity.getResult(); |
| assertEquals(Activity.RESULT_OK, result.resultCode); |
| |
| final Intent resultIntent = result.data; |
| final Uri resultUri = resultIntent.getData(); |
| final int flags = resultIntent.getFlags() |
| & (Intent.FLAG_GRANT_READ_URI_PERMISSION |
| | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); |
| mActivity.getContentResolver().takePersistableUriPermission(resultUri, flags); |
| return resultUri; |
| } |
| |
| private static String getTargetPackageName(Context context) { |
| final PackageManager pm = context.getPackageManager(); |
| |
| final Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); |
| intent.addCategory(Intent.CATEGORY_OPENABLE); |
| intent.setType("*/*"); |
| final ResolveInfo ri = pm.resolveActivity(intent, 0); |
| return ri.activityInfo.packageName; |
| } |
| |
| // TODO: replace with ProviderTestUtils |
| static String executeShellCommand(String command) throws IOException { |
| return executeShellCommand(command, |
| InstrumentationRegistry.getInstrumentation().getUiAutomation()); |
| } |
| |
| // TODO: replace with ProviderTestUtils |
| static String executeShellCommand(String command, UiAutomation uiAutomation) |
| throws IOException { |
| Log.v(TAG, "$ " + command); |
| ParcelFileDescriptor pfd = uiAutomation.executeShellCommand(command.toString()); |
| BufferedReader br = null; |
| try (InputStream in = new FileInputStream(pfd.getFileDescriptor());) { |
| br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); |
| String str = null; |
| StringBuilder out = new StringBuilder(); |
| while ((str = br.readLine()) != null) { |
| Log.v(TAG, "> " + str); |
| out.append(str); |
| } |
| return out.toString(); |
| } finally { |
| if (br != null) { |
| br.close(); |
| } |
| } |
| } |
| |
| // TODO: replace with ProviderTestUtils |
| static File stageFile(int resId, File file) throws IOException { |
| // The caller may be trying to stage into a location only available to |
| // the shell user, so we need to perform the entire copy as the shell |
| final Context context = InstrumentationRegistry.getTargetContext(); |
| final File dir = file.getParentFile(); |
| dir.mkdirs(); |
| if (!dir.exists()) { |
| throw new FileNotFoundException("Failed to create parent for " + file); |
| } |
| try (InputStream source = context.getResources().openRawResource(resId); |
| OutputStream target = new FileOutputStream(file)) { |
| FileUtils.copy(source, target); |
| } |
| return file; |
| } |
| |
| static File stageFileWithRawText(String rawText, File file) throws IOException { |
| final File dir = file.getParentFile(); |
| dir.mkdirs(); |
| if (!dir.exists()) { |
| throw new FileNotFoundException("Failed to create parent for " + file); |
| } |
| try (InputStream source = new ByteArrayInputStream( |
| rawText.getBytes(StandardCharsets.UTF_8)); |
| OutputStream target = new FileOutputStream(file)) { |
| FileUtils.copy(source, target); |
| } |
| return file; |
| } |
| } |