| /* | |
| * Copyright (C) 2013 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 com.example.android.vault; | |
| import static com.example.android.vault.EncryptedDocument.DATA_KEY_LENGTH; | |
| import static com.example.android.vault.EncryptedDocument.MAC_KEY_LENGTH; | |
| import static com.example.android.vault.Utils.closeQuietly; | |
| import static com.example.android.vault.Utils.closeWithErrorQuietly; | |
| import static com.example.android.vault.Utils.readFully; | |
| import static com.example.android.vault.Utils.writeFully; | |
| import android.content.Context; | |
| import android.content.SharedPreferences; | |
| import android.database.Cursor; | |
| import android.database.MatrixCursor; | |
| import android.database.MatrixCursor.RowBuilder; | |
| import android.os.Bundle; | |
| import android.os.CancellationSignal; | |
| import android.os.ParcelFileDescriptor; | |
| import android.provider.DocumentsContract; | |
| import android.provider.DocumentsContract.Document; | |
| import android.provider.DocumentsContract.Root; | |
| import android.provider.DocumentsProvider; | |
| import android.security.KeyChain; | |
| import android.text.TextUtils; | |
| import android.util.Log; | |
| import org.json.JSONArray; | |
| import org.json.JSONException; | |
| import org.json.JSONObject; | |
| import java.io.File; | |
| import java.io.FileNotFoundException; | |
| import java.io.IOException; | |
| import java.nio.charset.StandardCharsets; | |
| import java.security.GeneralSecurityException; | |
| import java.security.KeyStore; | |
| import java.security.SecureRandom; | |
| import javax.crypto.Mac; | |
| import javax.crypto.SecretKey; | |
| import javax.crypto.spec.SecretKeySpec; | |
| /** | |
| * Provider that encrypts both metadata and contents of documents stored inside. | |
| * Each document is stored as described by {@link EncryptedDocument} with | |
| * separate metadata and content sections. Directories are just | |
| * {@link EncryptedDocument} instances without a content section, and a list of | |
| * child documents included in the metadata section. | |
| * <p> | |
| * All content is encrypted/decrypted on demand through pipes, using | |
| * {@link ParcelFileDescriptor#createReliablePipe()} to detect and recover from | |
| * remote crashes and errors. | |
| * <p> | |
| * Our symmetric encryption key is stored on disk only after using | |
| * {@link SecretKeyWrapper} to "wrap" it using another public/private key pair | |
| * stored in the platform {@link KeyStore}. This allows us to protect our | |
| * symmetric key with hardware-backed keys, if supported. Devices without | |
| * hardware support still encrypt their keys while at rest, and the platform | |
| * always requires a user to present a PIN, password, or pattern to unlock the | |
| * KeyStore before use. | |
| */ | |
| public class VaultProvider extends DocumentsProvider { | |
| public static final String TAG = "Vault"; | |
| static final String AUTHORITY = "com.example.android.vault.provider"; | |
| static final String DEFAULT_ROOT_ID = "vault"; | |
| static final String DEFAULT_DOCUMENT_ID = "0"; | |
| /** JSON key storing array of all children documents in a directory. */ | |
| private static final String KEY_CHILDREN = "vault:children"; | |
| /** Key pointing to next available document ID. */ | |
| private static final String PREF_NEXT_ID = "next_id"; | |
| /** Blob used to derive {@link #mDataKey} from our secret key. */ | |
| private static final byte[] BLOB_DATA = "DATA".getBytes(StandardCharsets.UTF_8); | |
| /** Blob used to derive {@link #mMacKey} from our secret key. */ | |
| private static final byte[] BLOB_MAC = "MAC".getBytes(StandardCharsets.UTF_8); | |
| private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { | |
| Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE, | |
| Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES, Root.COLUMN_SUMMARY | |
| }; | |
| private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { | |
| Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, | |
| Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, | |
| }; | |
| private static String[] resolveRootProjection(String[] projection) { | |
| return projection != null ? projection : DEFAULT_ROOT_PROJECTION; | |
| } | |
| private static String[] resolveDocumentProjection(String[] projection) { | |
| return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; | |
| } | |
| private final Object mIdLock = new Object(); | |
| /** | |
| * Flag indicating that the {@link SecretKeyWrapper} public/private key is | |
| * hardware-backed. A software keystore is more vulnerable to offline | |
| * attacks if the device is compromised. | |
| */ | |
| private boolean mHardwareBacked; | |
| /** File where wrapped symmetric key is stored. */ | |
| private File mKeyFile; | |
| /** Directory where all encrypted documents are stored. */ | |
| private File mDocumentsDir; | |
| private SecretKey mDataKey; | |
| private SecretKey mMacKey; | |
| @Override | |
| public boolean onCreate() { | |
| mHardwareBacked = KeyChain.isBoundKeyAlgorithm("RSA"); | |
| mKeyFile = new File(getContext().getFilesDir(), "vault.key"); | |
| mDocumentsDir = new File(getContext().getFilesDir(), "documents"); | |
| mDocumentsDir.mkdirs(); | |
| try { | |
| // Load secret key and ensure our root document is ready. | |
| loadOrGenerateKeys(getContext(), mKeyFile); | |
| initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null); | |
| } catch (IOException e) { | |
| throw new IllegalStateException(e); | |
| } catch (GeneralSecurityException e) { | |
| throw new IllegalStateException(e); | |
| } | |
| return true; | |
| } | |
| /** | |
| * Used for testing. | |
| */ | |
| void wipeAllContents() throws IOException, GeneralSecurityException { | |
| for (File f : mDocumentsDir.listFiles()) { | |
| f.delete(); | |
| } | |
| initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null); | |
| } | |
| /** | |
| * Load our symmetric secret key and use it to derive two different data and | |
| * MAC keys. The symmetric secret key is stored securely on disk by wrapping | |
| * it with a public/private key pair, possibly backed by hardware. | |
| */ | |
| private void loadOrGenerateKeys(Context context, File keyFile) | |
| throws GeneralSecurityException, IOException { | |
| final SecretKeyWrapper wrapper = new SecretKeyWrapper(context, TAG); | |
| // Generate secret key if none exists | |
| if (!keyFile.exists()) { | |
| final byte[] raw = new byte[DATA_KEY_LENGTH]; | |
| new SecureRandom().nextBytes(raw); | |
| final SecretKey key = new SecretKeySpec(raw, "AES"); | |
| final byte[] wrapped = wrapper.wrap(key); | |
| writeFully(keyFile, wrapped); | |
| } | |
| // Even if we just generated the key, always read it back to ensure we | |
| // can read it successfully. | |
| final byte[] wrapped = readFully(keyFile); | |
| final SecretKey key = wrapper.unwrap(wrapped); | |
| final Mac mac = Mac.getInstance("HmacSHA256"); | |
| mac.init(key); | |
| // Derive two different keys for encryption and authentication. | |
| final byte[] rawDataKey = new byte[DATA_KEY_LENGTH]; | |
| final byte[] rawMacKey = new byte[MAC_KEY_LENGTH]; | |
| System.arraycopy(mac.doFinal(BLOB_DATA), 0, rawDataKey, 0, rawDataKey.length); | |
| System.arraycopy(mac.doFinal(BLOB_MAC), 0, rawMacKey, 0, rawMacKey.length); | |
| mDataKey = new SecretKeySpec(rawDataKey, "AES"); | |
| mMacKey = new SecretKeySpec(rawMacKey, "HmacSHA256"); | |
| } | |
| @Override | |
| public Cursor queryRoots(String[] projection) throws FileNotFoundException { | |
| final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); | |
| final RowBuilder row = result.newRow(); | |
| row.add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID); | |
| row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_LOCAL_ONLY | |
| | Root.FLAG_SUPPORTS_IS_CHILD); | |
| row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_label)); | |
| row.add(Root.COLUMN_DOCUMENT_ID, DEFAULT_DOCUMENT_ID); | |
| row.add(Root.COLUMN_ICON, R.drawable.ic_lock_lock); | |
| // Notify user in storage UI when key isn't hardware-backed | |
| if (!mHardwareBacked) { | |
| row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.info_software)); | |
| } | |
| return result; | |
| } | |
| private EncryptedDocument getDocument(long docId) throws GeneralSecurityException { | |
| final File file = new File(mDocumentsDir, String.valueOf(docId)); | |
| return new EncryptedDocument(docId, file, mDataKey, mMacKey); | |
| } | |
| /** | |
| * Include metadata for a document in the given result cursor. | |
| */ | |
| private void includeDocument(MatrixCursor result, long docId) | |
| throws IOException, GeneralSecurityException { | |
| final EncryptedDocument doc = getDocument(docId); | |
| if (!doc.getFile().exists()) { | |
| throw new FileNotFoundException("Missing document " + docId); | |
| } | |
| final JSONObject meta = doc.readMetadata(); | |
| int flags = 0; | |
| final String mimeType = meta.optString(Document.COLUMN_MIME_TYPE); | |
| if (Document.MIME_TYPE_DIR.equals(mimeType)) { | |
| flags |= Document.FLAG_DIR_SUPPORTS_CREATE; | |
| } else { | |
| flags |= Document.FLAG_SUPPORTS_WRITE; | |
| } | |
| flags |= Document.FLAG_SUPPORTS_RENAME; | |
| flags |= Document.FLAG_SUPPORTS_DELETE; | |
| final RowBuilder row = result.newRow(); | |
| row.add(Document.COLUMN_DOCUMENT_ID, meta.optLong(Document.COLUMN_DOCUMENT_ID)); | |
| row.add(Document.COLUMN_DISPLAY_NAME, meta.optString(Document.COLUMN_DISPLAY_NAME)); | |
| row.add(Document.COLUMN_SIZE, meta.optLong(Document.COLUMN_SIZE)); | |
| row.add(Document.COLUMN_MIME_TYPE, mimeType); | |
| row.add(Document.COLUMN_FLAGS, flags); | |
| row.add(Document.COLUMN_LAST_MODIFIED, meta.optLong(Document.COLUMN_LAST_MODIFIED)); | |
| } | |
| @Override | |
| public boolean isChildDocument(String parentDocumentId, String documentId) { | |
| if (TextUtils.equals(parentDocumentId, documentId)) { | |
| return true; | |
| } | |
| try { | |
| final long parentDocId = Long.parseLong(parentDocumentId); | |
| final EncryptedDocument parentDoc = getDocument(parentDocId); | |
| // Recursively search any children | |
| // TODO: consider building an index to optimize this check | |
| final JSONObject meta = parentDoc.readMetadata(); | |
| if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) { | |
| final JSONArray children = meta.getJSONArray(KEY_CHILDREN); | |
| for (int i = 0; i < children.length(); i++) { | |
| final String childDocumentId = children.getString(i); | |
| if (isChildDocument(childDocumentId, documentId)) { | |
| return true; | |
| } | |
| } | |
| } | |
| } catch (IOException e) { | |
| throw new IllegalStateException(e); | |
| } catch (GeneralSecurityException e) { | |
| throw new IllegalStateException(e); | |
| } catch (JSONException e) { | |
| throw new IllegalStateException(e); | |
| } | |
| return false; | |
| } | |
| @Override | |
| public String createDocument(String parentDocumentId, String mimeType, String displayName) | |
| throws FileNotFoundException { | |
| final long parentDocId = Long.parseLong(parentDocumentId); | |
| // Allocate the next available ID | |
| final long childDocId; | |
| synchronized (mIdLock) { | |
| final SharedPreferences prefs = getContext() | |
| .getSharedPreferences(PREF_NEXT_ID, Context.MODE_PRIVATE); | |
| childDocId = prefs.getLong(PREF_NEXT_ID, 1); | |
| if (!prefs.edit().putLong(PREF_NEXT_ID, childDocId + 1).commit()) { | |
| throw new IllegalStateException("Failed to allocate document ID"); | |
| } | |
| } | |
| try { | |
| initDocument(childDocId, mimeType, displayName); | |
| // Update parent to reference new child | |
| final EncryptedDocument parentDoc = getDocument(parentDocId); | |
| final JSONObject parentMeta = parentDoc.readMetadata(); | |
| parentMeta.accumulate(KEY_CHILDREN, childDocId); | |
| parentDoc.writeMetadataAndContent(parentMeta, null); | |
| return String.valueOf(childDocId); | |
| } catch (IOException e) { | |
| throw new IllegalStateException(e); | |
| } catch (GeneralSecurityException e) { | |
| throw new IllegalStateException(e); | |
| } catch (JSONException e) { | |
| throw new IllegalStateException(e); | |
| } | |
| } | |
| /** | |
| * Create document on disk, writing an initial metadata section. Someone | |
| * might come back later to write contents. | |
| */ | |
| private void initDocument(long docId, String mimeType, String displayName) | |
| throws IOException, GeneralSecurityException { | |
| final EncryptedDocument doc = getDocument(docId); | |
| if (doc.getFile().exists()) return; | |
| try { | |
| final JSONObject meta = new JSONObject(); | |
| meta.put(Document.COLUMN_DOCUMENT_ID, docId); | |
| meta.put(Document.COLUMN_MIME_TYPE, mimeType); | |
| meta.put(Document.COLUMN_DISPLAY_NAME, displayName); | |
| if (Document.MIME_TYPE_DIR.equals(mimeType)) { | |
| meta.put(KEY_CHILDREN, new JSONArray()); | |
| } | |
| doc.writeMetadataAndContent(meta, null); | |
| } catch (JSONException e) { | |
| throw new IOException(e); | |
| } | |
| } | |
| @Override | |
| public String renameDocument(String documentId, String displayName) | |
| throws FileNotFoundException { | |
| final long docId = Long.parseLong(documentId); | |
| try { | |
| final EncryptedDocument doc = getDocument(docId); | |
| final JSONObject meta = doc.readMetadata(); | |
| meta.put(Document.COLUMN_DISPLAY_NAME, displayName); | |
| doc.writeMetadataAndContent(meta, null); | |
| return null; | |
| } catch (IOException e) { | |
| throw new IllegalStateException(e); | |
| } catch (GeneralSecurityException e) { | |
| throw new IllegalStateException(e); | |
| } catch (JSONException e) { | |
| throw new IllegalStateException(e); | |
| } | |
| } | |
| @Override | |
| public void deleteDocument(String documentId) throws FileNotFoundException { | |
| final long docId = Long.parseLong(documentId); | |
| try { | |
| // Delete given document, any children documents under it, and any | |
| // references to it from parents. | |
| deleteDocumentTree(docId); | |
| deleteDocumentReferences(docId); | |
| } catch (IOException e) { | |
| throw new IllegalStateException(e); | |
| } catch (GeneralSecurityException e) { | |
| throw new IllegalStateException(e); | |
| } | |
| } | |
| /** | |
| * Recursively delete the given document and any children under it. | |
| */ | |
| private void deleteDocumentTree(long docId) throws IOException, GeneralSecurityException { | |
| final EncryptedDocument doc = getDocument(docId); | |
| final JSONObject meta = doc.readMetadata(); | |
| try { | |
| if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) { | |
| final JSONArray children = meta.getJSONArray(KEY_CHILDREN); | |
| for (int i = 0; i < children.length(); i++) { | |
| final long childDocId = children.getLong(i); | |
| deleteDocumentTree(childDocId); | |
| } | |
| } | |
| } catch (JSONException e) { | |
| throw new IOException(e); | |
| } | |
| if (!doc.getFile().delete()) { | |
| throw new IOException("Failed to delete " + docId); | |
| } | |
| } | |
| /** | |
| * Remove any references to the given document, usually when included as a | |
| * child of another directory. | |
| */ | |
| private void deleteDocumentReferences(long docId) { | |
| for (String name : mDocumentsDir.list()) { | |
| try { | |
| final long parentDocId = Long.parseLong(name); | |
| final EncryptedDocument parentDoc = getDocument(parentDocId); | |
| final JSONObject meta = parentDoc.readMetadata(); | |
| if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) { | |
| final JSONArray children = meta.getJSONArray(KEY_CHILDREN); | |
| if (maybeRemove(children, docId)) { | |
| Log.d(TAG, "Removed " + docId + " reference from " + name); | |
| parentDoc.writeMetadataAndContent(meta, null); | |
| getContext().getContentResolver().notifyChange( | |
| DocumentsContract.buildChildDocumentsUri(AUTHORITY, name), null, | |
| false); | |
| } | |
| } | |
| } catch (NumberFormatException ignored) { | |
| } catch (IOException e) { | |
| Log.w(TAG, "Failed to examine " + name, e); | |
| } catch (GeneralSecurityException e) { | |
| Log.w(TAG, "Failed to examine " + name, e); | |
| } catch (JSONException e) { | |
| Log.w(TAG, "Failed to examine " + name, e); | |
| } | |
| } | |
| } | |
| @Override | |
| public Cursor queryDocument(String documentId, String[] projection) | |
| throws FileNotFoundException { | |
| final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); | |
| try { | |
| includeDocument(result, Long.parseLong(documentId)); | |
| } catch (GeneralSecurityException e) { | |
| throw new IllegalStateException(e); | |
| } catch (IOException e) { | |
| throw new IllegalStateException(e); | |
| } | |
| return result; | |
| } | |
| @Override | |
| public Cursor queryChildDocuments( | |
| String parentDocumentId, String[] projection, String sortOrder) | |
| throws FileNotFoundException { | |
| final ExtrasMatrixCursor result = new ExtrasMatrixCursor( | |
| resolveDocumentProjection(projection)); | |
| result.setNotificationUri(getContext().getContentResolver(), | |
| DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId)); | |
| // Notify user in storage UI when key isn't hardware-backed | |
| if (!mHardwareBacked) { | |
| result.putString(DocumentsContract.EXTRA_INFO, | |
| getContext().getString(R.string.info_software_detail)); | |
| } | |
| try { | |
| final EncryptedDocument doc = getDocument(Long.parseLong(parentDocumentId)); | |
| final JSONObject meta = doc.readMetadata(); | |
| final JSONArray children = meta.getJSONArray(KEY_CHILDREN); | |
| for (int i = 0; i < children.length(); i++) { | |
| final long docId = children.getLong(i); | |
| includeDocument(result, docId); | |
| } | |
| } catch (IOException e) { | |
| throw new IllegalStateException(e); | |
| } catch (GeneralSecurityException e) { | |
| throw new IllegalStateException(e); | |
| } catch (JSONException e) { | |
| throw new IllegalStateException(e); | |
| } | |
| return result; | |
| } | |
| @Override | |
| public ParcelFileDescriptor openDocument( | |
| String documentId, String mode, CancellationSignal signal) | |
| throws FileNotFoundException { | |
| final long docId = Long.parseLong(documentId); | |
| try { | |
| final EncryptedDocument doc = getDocument(docId); | |
| if ("r".equals(mode)) { | |
| return startRead(doc); | |
| } else if ("w".equals(mode) || "wt".equals(mode)) { | |
| return startWrite(doc); | |
| } else { | |
| throw new IllegalArgumentException("Unsupported mode: " + mode); | |
| } | |
| } catch (IOException e) { | |
| throw new IllegalStateException(e); | |
| } catch (GeneralSecurityException e) { | |
| throw new IllegalStateException(e); | |
| } | |
| } | |
| /** | |
| * Kick off a thread to handle a read request for the given document. | |
| * Internally creates a pipe and returns the read end for returning to a | |
| * remote process. | |
| */ | |
| private ParcelFileDescriptor startRead(final EncryptedDocument doc) throws IOException { | |
| final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe(); | |
| final ParcelFileDescriptor readEnd = pipe[0]; | |
| final ParcelFileDescriptor writeEnd = pipe[1]; | |
| new Thread() { | |
| @Override | |
| public void run() { | |
| try { | |
| doc.readContent(writeEnd); | |
| Log.d(TAG, "Success reading " + doc); | |
| closeQuietly(writeEnd); | |
| } catch (IOException e) { | |
| Log.w(TAG, "Failed reading " + doc, e); | |
| closeWithErrorQuietly(writeEnd, e.toString()); | |
| } catch (GeneralSecurityException e) { | |
| Log.w(TAG, "Failed reading " + doc, e); | |
| closeWithErrorQuietly(writeEnd, e.toString()); | |
| } | |
| } | |
| }.start(); | |
| return readEnd; | |
| } | |
| /** | |
| * Kick off a thread to handle a write request for the given document. | |
| * Internally creates a pipe and returns the write end for returning to a | |
| * remote process. | |
| */ | |
| private ParcelFileDescriptor startWrite(final EncryptedDocument doc) throws IOException { | |
| final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe(); | |
| final ParcelFileDescriptor readEnd = pipe[0]; | |
| final ParcelFileDescriptor writeEnd = pipe[1]; | |
| new Thread() { | |
| @Override | |
| public void run() { | |
| try { | |
| final JSONObject meta = doc.readMetadata(); | |
| doc.writeMetadataAndContent(meta, readEnd); | |
| Log.d(TAG, "Success writing " + doc); | |
| closeQuietly(readEnd); | |
| } catch (IOException e) { | |
| Log.w(TAG, "Failed writing " + doc, e); | |
| closeWithErrorQuietly(readEnd, e.toString()); | |
| } catch (GeneralSecurityException e) { | |
| Log.w(TAG, "Failed writing " + doc, e); | |
| closeWithErrorQuietly(readEnd, e.toString()); | |
| } | |
| } | |
| }.start(); | |
| return writeEnd; | |
| } | |
| /** | |
| * Maybe remove the given value from a {@link JSONArray}. | |
| * | |
| * @return if the array was mutated. | |
| */ | |
| private static boolean maybeRemove(JSONArray array, long value) throws JSONException { | |
| boolean mutated = false; | |
| int i = 0; | |
| while (i < array.length()) { | |
| if (value == array.getLong(i)) { | |
| array.remove(i); | |
| mutated = true; | |
| } else { | |
| i++; | |
| } | |
| } | |
| return mutated; | |
| } | |
| /** | |
| * Simple extension of {@link MatrixCursor} that makes it easy to provide a | |
| * {@link Bundle} of extras. | |
| */ | |
| private static class ExtrasMatrixCursor extends MatrixCursor { | |
| private Bundle mExtras; | |
| public ExtrasMatrixCursor(String[] columnNames) { | |
| super(columnNames); | |
| } | |
| public void putString(String key, String value) { | |
| if (mExtras == null) { | |
| mExtras = new Bundle(); | |
| } | |
| mExtras.putString(key, value); | |
| } | |
| @Override | |
| public Bundle getExtras() { | |
| return mExtras; | |
| } | |
| } | |
| } |