| /* |
| * 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.telephony; |
| |
| import android.annotation.IntDef; |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.annotation.SdkConstant; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.ServiceConnection; |
| import android.content.SharedPreferences; |
| import android.net.Uri; |
| import android.os.IBinder; |
| import android.os.RemoteException; |
| import android.telephony.mbms.DownloadProgressListener; |
| import android.telephony.mbms.FileInfo; |
| import android.telephony.mbms.DownloadRequest; |
| import android.telephony.mbms.MbmsDownloadManagerCallback; |
| import android.telephony.mbms.MbmsDownloadReceiver; |
| import android.telephony.mbms.MbmsException; |
| import android.telephony.mbms.MbmsTempFileProvider; |
| import android.telephony.mbms.MbmsUtils; |
| import android.telephony.mbms.vendor.IMbmsDownloadService; |
| import android.util.Log; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| import java.util.List; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| import java.util.concurrent.atomic.AtomicReference; |
| |
| import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID; |
| |
| /** @hide */ |
| public class MbmsDownloadManager { |
| private static final String LOG_TAG = MbmsDownloadManager.class.getSimpleName(); |
| |
| /** @hide */ |
| // TODO: systemapi |
| @SdkConstant(SdkConstant.SdkConstantType.SERVICE_ACTION) |
| public static final String MBMS_DOWNLOAD_SERVICE_ACTION = |
| "android.telephony.action.EmbmsDownload"; |
| |
| /** |
| * Integer extra indicating the result code of the download. One of |
| * {@link #RESULT_SUCCESSFUL}, {@link #RESULT_EXPIRED}, or {@link #RESULT_CANCELLED}. |
| */ |
| public static final String EXTRA_RESULT = "android.telephony.mbms.extra.RESULT"; |
| |
| /** |
| * Extra containing the {@link android.telephony.mbms.FileInfo} for which the download result |
| * is for. Must not be null. |
| */ |
| public static final String EXTRA_FILE_INFO = "android.telephony.mbms.extra.FILE_INFO"; |
| |
| /** |
| * Extra containing a single {@link Uri} indicating the location of the successfully |
| * downloaded file. Set on the intent provided via |
| * {@link android.telephony.mbms.DownloadRequest.Builder#setAppIntent(Intent)}. |
| * Will always be set to a non-null value if {@link #EXTRA_RESULT} is set to |
| * {@link #RESULT_SUCCESSFUL}. |
| */ |
| public static final String EXTRA_COMPLETED_FILE_URI = |
| "android.telephony.mbms.extra.COMPLETED_FILE_URI"; |
| |
| public static final int RESULT_SUCCESSFUL = 1; |
| public static final int RESULT_CANCELLED = 2; |
| public static final int RESULT_EXPIRED = 3; |
| public static final int RESULT_IO_ERROR = 4; |
| // TODO - more results! |
| |
| /** @hide */ |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef({STATUS_UNKNOWN, STATUS_ACTIVELY_DOWNLOADING, STATUS_PENDING_DOWNLOAD, |
| STATUS_PENDING_REPAIR, STATUS_PENDING_DOWNLOAD_WINDOW}) |
| public @interface DownloadStatus {} |
| |
| public static final int STATUS_UNKNOWN = 0; |
| public static final int STATUS_ACTIVELY_DOWNLOADING = 1; |
| public static final int STATUS_PENDING_DOWNLOAD = 2; |
| public static final int STATUS_PENDING_REPAIR = 3; |
| public static final int STATUS_PENDING_DOWNLOAD_WINDOW = 4; |
| |
| private static AtomicBoolean sIsInitialized = new AtomicBoolean(false); |
| |
| private final Context mContext; |
| private int mSubscriptionId = INVALID_SUBSCRIPTION_ID; |
| private IBinder.DeathRecipient mDeathRecipient = new IBinder.DeathRecipient() { |
| @Override |
| public void binderDied() { |
| sendErrorToApp(MbmsException.ERROR_MIDDLEWARE_LOST, "Received death notification"); |
| } |
| }; |
| |
| private AtomicReference<IMbmsDownloadService> mService = new AtomicReference<>(null); |
| private final MbmsDownloadManagerCallback mCallback; |
| |
| private MbmsDownloadManager(Context context, MbmsDownloadManagerCallback callback, int subId) { |
| mContext = context; |
| mCallback = callback; |
| mSubscriptionId = subId; |
| } |
| |
| /** |
| * Create a new MbmsDownloadManager using the system default data subscription ID. |
| * See {@link #create(Context, MbmsDownloadManagerCallback, int)} |
| * |
| * @hide |
| */ |
| public static MbmsDownloadManager create(Context context, |
| MbmsDownloadManagerCallback listener) |
| throws MbmsException { |
| return create(context, listener, SubscriptionManager.getDefaultSubscriptionId()); |
| } |
| |
| /** |
| * Create a new MbmsDownloadManager using the given subscription ID. |
| * |
| * Note that this call will bind a remote service and that may take a bit. The instance of |
| * {@link MbmsDownloadManager} that is returned will not be ready for use until |
| * {@link MbmsDownloadManagerCallback#middlewareReady()} is called on the provided callback. |
| * If you attempt to use the manager before it is ready, a {@link MbmsException} will be thrown. |
| * |
| * This also may throw an {@link IllegalArgumentException} or an {@link IllegalStateException}. |
| * |
| * You may only have one instance of {@link MbmsDownloadManager} per UID. If you call this |
| * method while there is an active instance of {@link MbmsDownloadManager} in your process |
| * (in other words, one that has not had {@link #dispose()} called on it), this method will |
| * throw an {@link MbmsException}. If you call this method in a different process |
| * running under the same UID, an error will be indicated via |
| * {@link MbmsDownloadManagerCallback#error(int, String)}. |
| * |
| * Note that initialization may fail asynchronously. If you wish to try again after you |
| * receive such an asynchronous error, you must call dispose() on the instance of |
| * {@link MbmsDownloadManager} that you received before calling this method again. |
| * |
| * @param context The instance of {@link Context} to use |
| * @param listener A callback to get asynchronous error messages and file service updates. |
| * @param subscriptionId The data subscription ID to use |
| * @hide |
| */ |
| public static MbmsDownloadManager create(Context context, |
| MbmsDownloadManagerCallback listener, int subscriptionId) |
| throws MbmsException { |
| if (!sIsInitialized.compareAndSet(false, true)) { |
| throw new MbmsException(MbmsException.InitializationErrors.ERROR_DUPLICATE_INITIALIZE); |
| } |
| MbmsDownloadManager mdm = new MbmsDownloadManager(context, listener, subscriptionId); |
| try { |
| mdm.bindAndInitialize(); |
| } catch (MbmsException e) { |
| sIsInitialized.set(false); |
| throw e; |
| } |
| return mdm; |
| } |
| |
| private void bindAndInitialize() throws MbmsException { |
| MbmsUtils.startBinding(mContext, MBMS_DOWNLOAD_SERVICE_ACTION, |
| new ServiceConnection() { |
| @Override |
| public void onServiceConnected(ComponentName name, IBinder service) { |
| IMbmsDownloadService downloadService = |
| IMbmsDownloadService.Stub.asInterface(service); |
| int result; |
| try { |
| result = downloadService.initialize(mSubscriptionId, mCallback); |
| } catch (RemoteException e) { |
| Log.e(LOG_TAG, "Service died before initialization"); |
| sIsInitialized.set(false); |
| return; |
| } catch (RuntimeException e) { |
| Log.e(LOG_TAG, "Runtime exception during initialization"); |
| sendErrorToApp( |
| MbmsException.InitializationErrors.ERROR_UNABLE_TO_INITIALIZE, |
| e.toString()); |
| sIsInitialized.set(false); |
| return; |
| } |
| if (result != MbmsException.SUCCESS) { |
| sendErrorToApp(result, "Error returned during initialization"); |
| sIsInitialized.set(false); |
| return; |
| } |
| try { |
| downloadService.asBinder().linkToDeath(mDeathRecipient, 0); |
| } catch (RemoteException e) { |
| sendErrorToApp(MbmsException.ERROR_MIDDLEWARE_LOST, |
| "Middleware lost during initialization"); |
| sIsInitialized.set(false); |
| return; |
| } |
| mService.set(downloadService); |
| } |
| |
| @Override |
| public void onServiceDisconnected(ComponentName name) { |
| sIsInitialized.set(false); |
| mService.set(null); |
| } |
| }); |
| } |
| |
| /** |
| * An inspection API to retrieve the list of available |
| * {@link android.telephony.mbms.FileServiceInfo}s currently being advertised. |
| * The results are returned asynchronously via a call to |
| * {@link MbmsDownloadManagerCallback#fileServicesUpdated(List)} |
| * |
| * The serviceClasses argument lets the app filter on types of programming and is opaque data |
| * negotiated beforehand between the app and the carrier. |
| * |
| * This may throw an {@link MbmsException} containing one of the following errors: |
| * {@link MbmsException#ERROR_MIDDLEWARE_NOT_BOUND} |
| * {@link MbmsException#ERROR_MIDDLEWARE_LOST} |
| * |
| * Asynchronous error codes via the {@link MbmsDownloadManagerCallback#error(int, String)} |
| * callback can include any of the errors except: |
| * {@link MbmsException.StreamingErrors#ERROR_UNABLE_TO_START_SERVICE} |
| * |
| * @param classList A list of service classes which the app wishes to receive |
| * {@link MbmsDownloadManagerCallback#fileServicesUpdated(List)} callbacks |
| * about. Subsequent calls to this method will replace this list of service |
| * classes (i.e. the middleware will no longer send updates for services |
| * matching classes only in the old list). |
| */ |
| public void getFileServices(List<String> classList) throws MbmsException { |
| IMbmsDownloadService downloadService = mService.get(); |
| if (downloadService == null) { |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_NOT_BOUND); |
| } |
| try { |
| int returnCode = downloadService.getFileServices(mSubscriptionId, classList); |
| if (returnCode != MbmsException.SUCCESS) { |
| throw new MbmsException(returnCode); |
| } |
| } catch (RemoteException e) { |
| Log.w(LOG_TAG, "Remote process died"); |
| mService.set(null); |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_LOST); |
| } |
| } |
| |
| /** |
| * Sets the temp file root for downloads. |
| * All temp files created for the middleware to write to will be contained in the specified |
| * directory. Applications that wish to specify a location only need to call this method once |
| * as long their data is persisted in storage -- the argument will be stored both in a |
| * local instance of {@link android.content.SharedPreferences} and by the middleware. |
| * |
| * If this method is not called at least once before calling |
| * {@link #download(DownloadRequest, DownloadProgressListener)}, the framework |
| * will default to a directory formed by the concatenation of the app's files directory and |
| * {@link android.telephony.mbms.MbmsTempFileProvider#DEFAULT_TOP_LEVEL_TEMP_DIRECTORY}. |
| * |
| * Before calling this method, the app must cancel all of its pending |
| * {@link DownloadRequest}s via {@link #cancelDownload(DownloadRequest)}. If this is not done, |
| * an {@link MbmsException} will be thrown with code |
| * {@link MbmsException.DownloadErrors#ERROR_CANNOT_CHANGE_TEMP_FILE_ROOT} unless the |
| * provided directory is the same as what has been previously configured. |
| * |
| * The {@link File} supplied as a root temp file directory must already exist. If not, an |
| * {@link IllegalArgumentException} will be thrown. |
| * @param tempFileRootDirectory A directory to place temp files in. |
| */ |
| public void setTempFileRootDirectory(@NonNull File tempFileRootDirectory) |
| throws MbmsException { |
| IMbmsDownloadService downloadService = mService.get(); |
| if (downloadService == null) { |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_NOT_BOUND); |
| } |
| if (!tempFileRootDirectory.exists()) { |
| throw new IllegalArgumentException("Provided directory does not exist"); |
| } |
| if (!tempFileRootDirectory.isDirectory()) { |
| throw new IllegalArgumentException("Provided File is not a directory"); |
| } |
| String filePath; |
| try { |
| filePath = tempFileRootDirectory.getCanonicalPath(); |
| } catch (IOException e) { |
| throw new IllegalArgumentException("Unable to canonicalize the provided path: " + e); |
| } |
| |
| try { |
| int result = downloadService.setTempFileRootDirectory(mSubscriptionId, filePath); |
| if (result != MbmsException.SUCCESS) { |
| throw new MbmsException(result); |
| } |
| } catch (RemoteException e) { |
| mService.set(null); |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_LOST); |
| } |
| |
| SharedPreferences prefs = mContext.getSharedPreferences( |
| MbmsTempFileProvider.TEMP_FILE_ROOT_PREF_FILE_NAME, 0); |
| prefs.edit().putString(MbmsTempFileProvider.TEMP_FILE_ROOT_PREF_NAME, filePath).apply(); |
| } |
| |
| /** |
| * Retrieves the currently configured temp file root directory. Returns the file that was |
| * configured via {@link #setTempFileRootDirectory(File)} or the default directory |
| * {@link #download(DownloadRequest, DownloadProgressListener)} was called without ever setting |
| * the temp file root. If neither method has been called since the last time the app's shared |
| * preferences were reset, returns null. |
| * |
| * @return A {@link File} pointing to the configured temp file directory, or null if not yet |
| * configured. |
| */ |
| public @Nullable File getTempFileRootDirectory() { |
| SharedPreferences prefs = mContext.getSharedPreferences( |
| MbmsTempFileProvider.TEMP_FILE_ROOT_PREF_FILE_NAME, 0); |
| String path = prefs.getString(MbmsTempFileProvider.TEMP_FILE_ROOT_PREF_NAME, null); |
| if (path != null) { |
| return new File(path); |
| } |
| return null; |
| } |
| |
| /** |
| * Requests a download of a file that is available via multicast. |
| * |
| * downloadListener is an optional callback object which can be used to get progress reports |
| * of a currently occuring download. Note this can only run while the calling app |
| * is running, so future downloads will simply result in resultIntents being sent |
| * for completed or errored-out downloads. A NULL indicates no callbacks are needed. |
| * |
| * May throw an {@link IllegalArgumentException} |
| * |
| * If {@link #setTempFileRootDirectory(File)} has not called after the app has been installed, |
| * this method will create a directory at the default location defined at |
| * {@link MbmsTempFileProvider#DEFAULT_TOP_LEVEL_TEMP_DIRECTORY} and store that as the temp |
| * file root directory. |
| * |
| * Asynchronous errors through the listener include any of the errors |
| * |
| * @param request The request that specifies what should be downloaded |
| * @param progressListener Optional listener that will be provided progress updates |
| * if the app is running. |
| */ |
| public void download(DownloadRequest request, DownloadProgressListener progressListener) |
| throws MbmsException { |
| IMbmsDownloadService downloadService = mService.get(); |
| if (downloadService == null) { |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_NOT_BOUND); |
| } |
| |
| // Check to see whether the app's set a temp root dir yet, and set it if not. |
| SharedPreferences prefs = mContext.getSharedPreferences( |
| MbmsTempFileProvider.TEMP_FILE_ROOT_PREF_FILE_NAME, 0); |
| if (prefs.getString(MbmsTempFileProvider.TEMP_FILE_ROOT_PREF_NAME, null) == null) { |
| File tempRootDirectory = new File(mContext.getFilesDir(), |
| MbmsTempFileProvider.DEFAULT_TOP_LEVEL_TEMP_DIRECTORY); |
| tempRootDirectory.mkdirs(); |
| setTempFileRootDirectory(tempRootDirectory); |
| } |
| |
| checkValidDownloadDestination(request); |
| writeDownloadRequestToken(request); |
| try { |
| downloadService.download(request, progressListener); |
| } catch (RemoteException e) { |
| mService.set(null); |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_LOST); |
| } |
| } |
| |
| /** |
| * Returns a list of pending {@link DownloadRequest}s that originated from this application. |
| * A pending request is one that was issued via |
| * {@link #download(DownloadRequest, DownloadProgressListener)} but not cancelled through |
| * {@link #cancelDownload(DownloadRequest)}. |
| * @return A list, possibly empty, of {@link DownloadRequest}s |
| */ |
| public @NonNull List<DownloadRequest> listPendingDownloads() throws MbmsException { |
| IMbmsDownloadService downloadService = mService.get(); |
| if (downloadService == null) { |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_NOT_BOUND); |
| } |
| |
| try { |
| return downloadService.listPendingDownloads(mSubscriptionId); |
| } catch (RemoteException e) { |
| mService.set(null); |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_LOST); |
| } |
| } |
| |
| /** |
| * Attempts to cancel the specified {@link DownloadRequest}. |
| * |
| * If the middleware is not aware of the specified download request, an MbmsException will be |
| * thrown with error code {@link MbmsException.DownloadErrors#ERROR_UNKNOWN_DOWNLOAD_REQUEST}. |
| * |
| * If this method returns without throwing an exception, you may assume that cancellation |
| * was successful. |
| * @param downloadRequest The download request that you wish to cancel. |
| */ |
| public void cancelDownload(DownloadRequest downloadRequest) throws MbmsException { |
| IMbmsDownloadService downloadService = mService.get(); |
| if (downloadService == null) { |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_NOT_BOUND); |
| } |
| |
| try { |
| int result = downloadService.cancelDownload(downloadRequest); |
| if (result != MbmsException.SUCCESS) { |
| throw new MbmsException(result); |
| } |
| } catch (RemoteException e) { |
| mService.set(null); |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_LOST); |
| } |
| deleteDownloadRequestToken(downloadRequest); |
| } |
| |
| /** |
| * Gets information about the status of a file pending download. |
| * |
| * If the middleware has not yet been properly initialized or if it has no records of the |
| * file indicated by {@code fileInfo} being associated with {@code downloadRequest}, |
| * {@link #STATUS_UNKNOWN} will be returned. |
| * |
| * @param downloadRequest The download request to query. |
| * @param fileInfo The particular file within the request to get information on. |
| * @return The status of the download. |
| */ |
| @DownloadStatus |
| public int getDownloadStatus(DownloadRequest downloadRequest, FileInfo fileInfo) |
| throws MbmsException { |
| IMbmsDownloadService downloadService = mService.get(); |
| if (downloadService == null) { |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_NOT_BOUND); |
| } |
| |
| try { |
| return downloadService.getDownloadStatus(downloadRequest, fileInfo); |
| } catch (RemoteException e) { |
| mService.set(null); |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_LOST); |
| } |
| } |
| |
| /** |
| * Resets the middleware's knowledge of previously-downloaded files in this download request. |
| * |
| * Normally, the middleware keeps track of the hashes of downloaded files and won't re-download |
| * files whose server-reported hash matches one of the already-downloaded files. This means |
| * that if the file is accidentally deleted by the user or by the app, the middleware will |
| * not try to download it again. |
| * This method will reset the middleware's cache of hashes for the provided |
| * {@link DownloadRequest}, so that previously downloaded content will be downloaded again |
| * when available. |
| * This will not interrupt in-progress downloads. |
| * |
| * If the middleware is not aware of the specified download request, an MbmsException will be |
| * thrown with error code {@link MbmsException.DownloadErrors#ERROR_UNKNOWN_DOWNLOAD_REQUEST}. |
| * |
| * May throw a {@link MbmsException} with error code |
| * @param downloadRequest The request to re-download files for. |
| */ |
| public void resetDownloadKnowledge(DownloadRequest downloadRequest) throws MbmsException { |
| IMbmsDownloadService downloadService = mService.get(); |
| if (downloadService == null) { |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_NOT_BOUND); |
| } |
| |
| try { |
| int result = downloadService.resetDownloadKnowledge(downloadRequest); |
| if (result != MbmsException.SUCCESS) { |
| throw new MbmsException(result); |
| } |
| } catch (RemoteException e) { |
| mService.set(null); |
| throw new MbmsException(MbmsException.ERROR_MIDDLEWARE_LOST); |
| } |
| } |
| |
| public void dispose() { |
| try { |
| IMbmsDownloadService downloadService = mService.get(); |
| if (downloadService == null) { |
| Log.i(LOG_TAG, "Service already dead"); |
| return; |
| } |
| downloadService.dispose(mSubscriptionId); |
| } catch (RemoteException e) { |
| // Ignore |
| Log.i(LOG_TAG, "Remote exception while disposing of service"); |
| } finally { |
| mService.set(null); |
| sIsInitialized.set(false); |
| } |
| } |
| |
| private void writeDownloadRequestToken(DownloadRequest request) { |
| File token = getDownloadRequestTokenPath(request); |
| if (!token.getParentFile().exists()) { |
| token.getParentFile().mkdirs(); |
| } |
| if (token.exists()) { |
| Log.w(LOG_TAG, "Download token " + token.getName() + " already exists"); |
| return; |
| } |
| try { |
| if (!token.createNewFile()) { |
| throw new RuntimeException("Failed to create download token for request " |
| + request); |
| } |
| } catch (IOException e) { |
| throw new RuntimeException("Failed to create download token for request " + request |
| + " due to IOException " + e); |
| } |
| } |
| |
| private void deleteDownloadRequestToken(DownloadRequest request) { |
| File token = getDownloadRequestTokenPath(request); |
| if (!token.isFile()) { |
| Log.w(LOG_TAG, "Attempting to delete non-existent download token at " + token); |
| return; |
| } |
| if (!token.delete()) { |
| Log.w(LOG_TAG, "Couldn't delete download token at " + token); |
| } |
| } |
| |
| private File getDownloadRequestTokenPath(DownloadRequest request) { |
| File tempFileLocation = MbmsUtils.getEmbmsTempFileDirForService(mContext, |
| request.getFileServiceId()); |
| String downloadTokenFileName = request.getHash() |
| + MbmsDownloadReceiver.DOWNLOAD_TOKEN_SUFFIX; |
| return new File(tempFileLocation, downloadTokenFileName); |
| } |
| |
| /** |
| * Verifies the following: |
| * If a request is multi-part, |
| * 1. Destination Uri must exist and be a directory |
| * 2. Directory specified must contain no files. |
| * Otherwise |
| * 1. The file specified by the destination Uri must not exist. |
| */ |
| private void checkValidDownloadDestination(DownloadRequest request) { |
| File toFile = new File(request.getDestinationUri().getSchemeSpecificPart()); |
| if (request.isMultipartDownload()) { |
| if (!toFile.isDirectory()) { |
| throw new IllegalArgumentException("Multipart download must specify valid " + |
| "destination directory."); |
| } |
| if (toFile.listFiles().length > 0) { |
| throw new IllegalArgumentException("Destination directory must be clear of all " + |
| "files."); |
| } |
| } else { |
| if (toFile.exists()) { |
| throw new IllegalArgumentException("Destination file must not exist."); |
| } |
| } |
| } |
| |
| private void sendErrorToApp(int errorCode, String message) { |
| try { |
| mCallback.error(errorCode, message); |
| } catch (RemoteException e) { |
| // Ignore, should not happen locally. |
| } |
| } |
| } |