2015-09-08 15:58:13 +00:00
|
|
|
/**
|
2018-09-11 22:27:47 +00:00
|
|
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
2015-09-08 15:58:13 +00:00
|
|
|
*
|
2018-02-17 02:24:55 +00:00
|
|
|
* This source code is licensed under the MIT license found in the
|
|
|
|
* LICENSE file in the root directory of this source tree.
|
2015-09-08 15:58:13 +00:00
|
|
|
*/
|
|
|
|
|
2019-02-24 04:29:24 +00:00
|
|
|
#import "RNCCameraRollManager.h"
|
2015-09-08 15:58:13 +00:00
|
|
|
|
|
|
|
#import <CoreLocation/CoreLocation.h>
|
|
|
|
#import <Foundation/Foundation.h>
|
|
|
|
#import <UIKit/UIKit.h>
|
2017-11-29 20:07:49 +00:00
|
|
|
#import <Photos/Photos.h>
|
2018-06-19 20:18:51 +00:00
|
|
|
#import <dlfcn.h>
|
|
|
|
#import <objc/runtime.h>
|
2019-01-08 00:11:52 +00:00
|
|
|
#import <MobileCoreServices/UTType.h>
|
2015-09-08 15:58:13 +00:00
|
|
|
|
2016-11-23 15:47:52 +00:00
|
|
|
#import <React/RCTBridge.h>
|
|
|
|
#import <React/RCTConvert.h>
|
|
|
|
#import <React/RCTImageLoader.h>
|
|
|
|
#import <React/RCTLog.h>
|
|
|
|
#import <React/RCTUtils.h>
|
|
|
|
|
2016-01-20 19:03:22 +00:00
|
|
|
#import "RCTAssetsLibraryRequestHandler.h"
|
2015-09-08 15:58:13 +00:00
|
|
|
|
2019-01-08 00:11:52 +00:00
|
|
|
@implementation RCTConvert (PHAssetCollectionSubtype)
|
|
|
|
|
|
|
|
RCT_ENUM_CONVERTER(PHAssetCollectionSubtype, (@{
|
|
|
|
@"album": @(PHAssetCollectionSubtypeAny),
|
|
|
|
@"all": @(PHAssetCollectionSubtypeAny),
|
|
|
|
@"event": @(PHAssetCollectionSubtypeAlbumSyncedEvent),
|
|
|
|
@"faces": @(PHAssetCollectionSubtypeAlbumSyncedFaces),
|
|
|
|
@"library": @(PHAssetCollectionSubtypeSmartAlbumUserLibrary),
|
|
|
|
@"photo-stream": @(PHAssetCollectionSubtypeAlbumMyPhotoStream), // incorrect, but legacy
|
|
|
|
@"photostream": @(PHAssetCollectionSubtypeAlbumMyPhotoStream),
|
|
|
|
@"saved-photos": @(PHAssetCollectionSubtypeAny), // incorrect, but legacy
|
|
|
|
@"savedphotos": @(PHAssetCollectionSubtypeAny), // This was ALAssetsGroupSavedPhotos, seems to have no direct correspondence in PHAssetCollectionSubtype
|
|
|
|
}), PHAssetCollectionSubtypeAny, integerValue)
|
|
|
|
|
2018-12-22 08:16:33 +00:00
|
|
|
|
2019-01-08 00:11:52 +00:00
|
|
|
@end
|
|
|
|
|
|
|
|
@implementation RCTConvert (PHFetchOptions)
|
|
|
|
|
|
|
|
+ (PHFetchOptions *)PHFetchOptionsFromMediaType:(NSString *)mediaType
|
2018-12-22 08:16:33 +00:00
|
|
|
{
|
2019-01-08 00:11:52 +00:00
|
|
|
// This is not exhaustive in terms of supported media type predicates; more can be added in the future
|
|
|
|
NSString *const lowercase = [mediaType lowercaseString];
|
2018-12-22 08:16:33 +00:00
|
|
|
|
2019-01-08 00:11:52 +00:00
|
|
|
if ([lowercase isEqualToString:@"photos"]) {
|
|
|
|
PHFetchOptions *const options = [PHFetchOptions new];
|
|
|
|
options.predicate = [NSPredicate predicateWithFormat:@"mediaType = %d", PHAssetMediaTypeImage];
|
|
|
|
return options;
|
|
|
|
} else if ([lowercase isEqualToString:@"videos"]) {
|
|
|
|
PHFetchOptions *const options = [PHFetchOptions new];
|
|
|
|
options.predicate = [NSPredicate predicateWithFormat:@"mediaType = %d", PHAssetMediaTypeVideo];
|
|
|
|
return options;
|
|
|
|
} else {
|
|
|
|
if (![lowercase isEqualToString:@"all"]) {
|
|
|
|
RCTLogError(@"Invalid filter option: '%@'. Expected one of 'photos',"
|
|
|
|
"'videos' or 'all'.", mediaType);
|
|
|
|
}
|
|
|
|
// This case includes the "all" mediatype
|
|
|
|
return nil;
|
2015-11-03 22:45:46 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@end
|
|
|
|
|
2019-02-24 04:29:24 +00:00
|
|
|
@implementation RNCCameraRollManager
|
2015-09-08 15:58:13 +00:00
|
|
|
|
|
|
|
RCT_EXPORT_MODULE()
|
|
|
|
|
|
|
|
@synthesize bridge = _bridge;
|
|
|
|
|
2018-12-22 08:16:33 +00:00
|
|
|
static NSString *const kErrorUnableToSave = @"E_UNABLE_TO_SAVE";
|
2019-01-08 00:11:52 +00:00
|
|
|
static NSString *const kErrorUnableToLoad = @"E_UNABLE_TO_LOAD";
|
2016-01-21 16:07:01 +00:00
|
|
|
|
2019-01-10 21:07:15 +00:00
|
|
|
static NSString *const kErrorAuthRestricted = @"E_PHOTO_LIBRARY_AUTH_RESTRICTED";
|
|
|
|
static NSString *const kErrorAuthDenied = @"E_PHOTO_LIBRARY_AUTH_DENIED";
|
|
|
|
|
|
|
|
typedef void (^PhotosAuthorizedBlock)(void);
|
|
|
|
|
|
|
|
static void requestPhotoLibraryAccess(RCTPromiseRejectBlock reject, PhotosAuthorizedBlock authorizedBlock) {
|
|
|
|
PHAuthorizationStatus authStatus = [PHPhotoLibrary authorizationStatus];
|
|
|
|
if (authStatus == PHAuthorizationStatusRestricted) {
|
|
|
|
reject(kErrorAuthRestricted, @"Access to photo library is restricted", nil);
|
|
|
|
} else if (authStatus == PHAuthorizationStatusAuthorized) {
|
|
|
|
authorizedBlock();
|
|
|
|
} else if (authStatus == PHAuthorizationStatusNotDetermined) {
|
|
|
|
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
|
|
|
|
requestPhotoLibraryAccess(reject, authorizedBlock);
|
|
|
|
}];
|
|
|
|
} else {
|
|
|
|
reject(kErrorAuthDenied, @"Access to photo library was denied", nil);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
Allow CameraRoll to export videos
Summary:
This PR adds the ability to export videos to the CameraRoll on both Android and iOS (previously only photos were possible, at least on iOS). The API has changed as follows:
```
// old
saveImageWithTag(tag: string): Promise<string>
// new
saveToCameraRoll(tag: string, type?: 'photo' | 'video'): Promise<string>
```
if no `type` parameter is passed, `video` is inferred if the tag ends with ".mov" or ".mp4", otherwise `photo` is assumed.
I've left in the `saveImageWithTag` method for now with a deprecation warning.
**Test plan (required)**
I created the following very simple app to test exporting photos and videos to the CameraRoll, and ran it on both iOS and Android. The functionality works as intended on both platforms.
```js
// index.js
/**
* Sample React Native App
* https://github.com/facebook/react-native
* flow
*/
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View,
CameraRoll,
} from 'react-native';
import FS fro
Closes https://github.com/facebook/react-native/pull/7988
Differential Revision: D3401251
Pulled By: nicklockwood
fbshipit-source-id: af3fc24e6fa5b84ac377e9173f3709c6f9795f20
2016-06-07 23:37:48 +00:00
|
|
|
RCT_EXPORT_METHOD(saveToCameraRoll:(NSURLRequest *)request
|
|
|
|
type:(NSString *)type
|
2016-01-21 16:07:01 +00:00
|
|
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
|
|
reject:(RCTPromiseRejectBlock)reject)
|
2015-09-08 15:58:13 +00:00
|
|
|
{
|
2019-01-08 00:11:52 +00:00
|
|
|
__block PHObjectPlaceholder *placeholder;
|
|
|
|
|
|
|
|
// We load images and videos differently.
|
|
|
|
// Images have many custom loaders which can load images from ALAssetsLibrary URLs, PHPhotoLibrary
|
|
|
|
// URLs, `data:` URIs, etc. Video URLs are passed directly through for now; it may be nice to support
|
|
|
|
// more ways of loading videos in the future.
|
|
|
|
__block NSURL *inputURI = nil;
|
|
|
|
__block UIImage *inputImage = nil;
|
|
|
|
|
|
|
|
void (^saveBlock)(void) = ^void() {
|
|
|
|
// performChanges and the completionHandler are called on
|
|
|
|
// arbitrary threads, not the main thread - this is safe
|
|
|
|
// for now since all JS is queued and executed on a single thread.
|
|
|
|
// We should reevaluate this if that assumption changes.
|
|
|
|
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
|
|
|
|
PHAssetChangeRequest *changeRequest;
|
|
|
|
|
|
|
|
// Defaults to "photo". `type` is an optional param.
|
|
|
|
if ([type isEqualToString:@"video"]) {
|
|
|
|
changeRequest = [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:inputURI];
|
|
|
|
} else {
|
|
|
|
changeRequest = [PHAssetChangeRequest creationRequestForAssetFromImage:inputImage];
|
|
|
|
}
|
|
|
|
|
|
|
|
placeholder = [changeRequest placeholderForCreatedAsset];
|
|
|
|
} completionHandler:^(BOOL success, NSError * _Nullable error) {
|
|
|
|
if (success) {
|
|
|
|
NSString *uri = [NSString stringWithFormat:@"ph://%@", [placeholder localIdentifier]];
|
|
|
|
resolve(uri);
|
|
|
|
} else {
|
|
|
|
reject(kErrorUnableToSave, nil, error);
|
|
|
|
}
|
|
|
|
}];
|
|
|
|
};
|
2019-01-10 21:07:15 +00:00
|
|
|
|
|
|
|
void (^loadBlock)(void) = ^void() {
|
|
|
|
if ([type isEqualToString:@"video"]) {
|
|
|
|
inputURI = request.URL;
|
2019-01-08 00:11:52 +00:00
|
|
|
saveBlock();
|
2019-01-10 21:07:15 +00:00
|
|
|
} else {
|
|
|
|
[self.bridge.imageLoader loadImageWithURLRequest:request callback:^(NSError *error, UIImage *image) {
|
|
|
|
if (error) {
|
|
|
|
reject(kErrorUnableToLoad, nil, error);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
inputImage = image;
|
|
|
|
saveBlock();
|
|
|
|
}];
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
requestPhotoLibraryAccess(reject, loadBlock);
|
2015-09-08 15:58:13 +00:00
|
|
|
}
|
|
|
|
|
2016-01-21 16:07:01 +00:00
|
|
|
static void RCTResolvePromise(RCTPromiseResolveBlock resolve,
|
|
|
|
NSArray<NSDictionary<NSString *, id> *> *assets,
|
|
|
|
BOOL hasNextPage)
|
2015-09-08 15:58:13 +00:00
|
|
|
{
|
|
|
|
if (!assets.count) {
|
2016-02-10 15:24:38 +00:00
|
|
|
resolve(@{
|
2015-11-14 18:25:00 +00:00
|
|
|
@"edges": assets,
|
|
|
|
@"page_info": @{
|
|
|
|
@"has_next_page": @NO,
|
|
|
|
}
|
2016-02-10 15:24:38 +00:00
|
|
|
});
|
2015-09-08 15:58:13 +00:00
|
|
|
return;
|
|
|
|
}
|
2016-02-10 15:24:38 +00:00
|
|
|
resolve(@{
|
2015-11-14 18:25:00 +00:00
|
|
|
@"edges": assets,
|
|
|
|
@"page_info": @{
|
|
|
|
@"start_cursor": assets[0][@"node"][@"image"][@"uri"],
|
|
|
|
@"end_cursor": assets[assets.count - 1][@"node"][@"image"][@"uri"],
|
|
|
|
@"has_next_page": @(hasNextPage),
|
|
|
|
}
|
2016-02-10 15:24:38 +00:00
|
|
|
});
|
2015-09-08 15:58:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
RCT_EXPORT_METHOD(getPhotos:(NSDictionary *)params
|
2016-01-21 16:07:01 +00:00
|
|
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
|
|
reject:(RCTPromiseRejectBlock)reject)
|
2015-09-08 15:58:13 +00:00
|
|
|
{
|
2016-08-18 14:16:26 +00:00
|
|
|
checkPhotoLibraryConfig();
|
|
|
|
|
2019-01-08 00:11:52 +00:00
|
|
|
NSUInteger const first = [RCTConvert NSInteger:params[@"first"]];
|
|
|
|
NSString *const afterCursor = [RCTConvert NSString:params[@"after"]];
|
|
|
|
NSString *const groupName = [RCTConvert NSString:params[@"groupName"]];
|
|
|
|
NSString *const groupTypes = [[RCTConvert NSString:params[@"groupTypes"]] lowercaseString];
|
|
|
|
NSString *const mediaType = [RCTConvert NSString:params[@"assetType"]];
|
|
|
|
NSArray<NSString *> *const mimeTypes = [RCTConvert NSStringArray:params[@"mimeTypes"]];
|
|
|
|
|
|
|
|
// If groupTypes is "all", we want to fetch the SmartAlbum "all photos". Otherwise, all
|
|
|
|
// other groupTypes values require the "album" collection type.
|
|
|
|
PHAssetCollectionType const collectionType = ([groupTypes isEqualToString:@"all"]
|
|
|
|
? PHAssetCollectionTypeSmartAlbum
|
|
|
|
: PHAssetCollectionTypeAlbum);
|
|
|
|
PHAssetCollectionSubtype const collectionSubtype = [RCTConvert PHAssetCollectionSubtype:groupTypes];
|
|
|
|
|
|
|
|
// Predicate for fetching assets within a collection
|
|
|
|
PHFetchOptions *const assetFetchOptions = [RCTConvert PHFetchOptionsFromMediaType:mediaType];
|
|
|
|
assetFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]];
|
2015-09-08 15:58:13 +00:00
|
|
|
|
|
|
|
BOOL __block foundAfter = NO;
|
|
|
|
BOOL __block hasNextPage = NO;
|
2016-01-21 16:07:01 +00:00
|
|
|
BOOL __block resolvedPromise = NO;
|
2015-11-14 18:25:00 +00:00
|
|
|
NSMutableArray<NSDictionary<NSString *, id> *> *assets = [NSMutableArray new];
|
2018-12-22 08:16:33 +00:00
|
|
|
|
2019-01-08 00:11:52 +00:00
|
|
|
// Filter collection name ("group")
|
|
|
|
PHFetchOptions *const collectionFetchOptions = [PHFetchOptions new];
|
|
|
|
collectionFetchOptions.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"endDate" ascending:NO]];
|
|
|
|
if (groupName != nil) {
|
|
|
|
collectionFetchOptions.predicate = [NSPredicate predicateWithFormat:[NSString stringWithFormat:@"localizedTitle == '%@'", groupName]];
|
|
|
|
}
|
2019-01-10 21:07:15 +00:00
|
|
|
|
|
|
|
requestPhotoLibraryAccess(reject, ^{
|
|
|
|
PHFetchResult<PHAssetCollection *> *const assetCollectionFetchResult = [PHAssetCollection fetchAssetCollectionsWithType:collectionType subtype:collectionSubtype options:collectionFetchOptions];
|
|
|
|
[assetCollectionFetchResult enumerateObjectsUsingBlock:^(PHAssetCollection * _Nonnull assetCollection, NSUInteger collectionIdx, BOOL * _Nonnull stopCollections) {
|
|
|
|
// Enumerate assets within the collection
|
|
|
|
PHFetchResult<PHAsset *> *const assetsFetchResult = [PHAsset fetchAssetsInAssetCollection:assetCollection options:assetFetchOptions];
|
|
|
|
|
|
|
|
[assetsFetchResult enumerateObjectsUsingBlock:^(PHAsset * _Nonnull asset, NSUInteger assetIdx, BOOL * _Nonnull stopAssets) {
|
|
|
|
NSString *const uri = [NSString stringWithFormat:@"ph://%@", [asset localIdentifier]];
|
|
|
|
if (afterCursor && !foundAfter) {
|
|
|
|
if ([afterCursor isEqualToString:uri]) {
|
|
|
|
foundAfter = YES;
|
|
|
|
}
|
|
|
|
return; // skip until we get to the first one
|
2019-01-08 00:11:52 +00:00
|
|
|
}
|
2019-01-10 21:07:15 +00:00
|
|
|
|
|
|
|
// Get underlying resources of an asset - this includes files as well as details about edited PHAssets
|
|
|
|
if ([mimeTypes count] > 0) {
|
|
|
|
NSArray<PHAssetResource *> *const assetResources = [PHAssetResource assetResourcesForAsset:asset];
|
|
|
|
if (![assetResources firstObject]) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
PHAssetResource *const _Nonnull resource = [assetResources firstObject];
|
|
|
|
CFStringRef const uti = (__bridge CFStringRef _Nonnull)(resource.uniformTypeIdentifier);
|
|
|
|
NSString *const mimeType = (NSString *)CFBridgingRelease(UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType));
|
|
|
|
|
|
|
|
BOOL __block mimeTypeFound = NO;
|
|
|
|
[mimeTypes enumerateObjectsUsingBlock:^(NSString * _Nonnull mimeTypeFilter, NSUInteger idx, BOOL * _Nonnull stop) {
|
|
|
|
if ([mimeType isEqualToString:mimeTypeFilter]) {
|
|
|
|
mimeTypeFound = YES;
|
|
|
|
*stop = YES;
|
|
|
|
}
|
|
|
|
}];
|
|
|
|
|
|
|
|
if (!mimeTypeFound) {
|
|
|
|
return;
|
2018-12-22 08:16:33 +00:00
|
|
|
}
|
2015-09-08 15:58:13 +00:00
|
|
|
}
|
2019-01-10 21:07:15 +00:00
|
|
|
|
|
|
|
// If we've accumulated enough results to resolve a single promise
|
|
|
|
if (first == assets.count) {
|
|
|
|
*stopAssets = YES;
|
|
|
|
*stopCollections = YES;
|
|
|
|
hasNextPage = YES;
|
|
|
|
RCTAssert(resolvedPromise == NO, @"Resolved the promise before we finished processing the results.");
|
|
|
|
RCTResolvePromise(resolve, assets, hasNextPage);
|
|
|
|
resolvedPromise = YES;
|
|
|
|
return;
|
2019-01-08 00:11:52 +00:00
|
|
|
}
|
2019-01-10 21:07:15 +00:00
|
|
|
|
|
|
|
NSString *const assetMediaTypeLabel = (asset.mediaType == PHAssetMediaTypeVideo
|
|
|
|
? @"video"
|
|
|
|
: (asset.mediaType == PHAssetMediaTypeImage
|
|
|
|
? @"image"
|
|
|
|
: (asset.mediaType == PHAssetMediaTypeAudio
|
|
|
|
? @"audio"
|
|
|
|
: @"unknown")));
|
|
|
|
CLLocation *const loc = asset.location;
|
|
|
|
|
|
|
|
// A note on isStored: in the previous code that used ALAssets, isStored
|
|
|
|
// was always set to YES, probably because iCloud-synced images were never returned (?).
|
|
|
|
// To get the "isStored" information and filename, we would need to actually request the
|
|
|
|
// image data from the image manager. Those operations could get really expensive and
|
|
|
|
// would definitely utilize the disk too much.
|
|
|
|
// Thus, this field is actually not reliable.
|
|
|
|
// Note that Android also does not return the `isStored` field at all.
|
|
|
|
[assets addObject:@{
|
|
|
|
@"node": @{
|
|
|
|
@"type": assetMediaTypeLabel, // TODO: switch to mimeType?
|
|
|
|
@"group_name": [assetCollection localizedTitle],
|
|
|
|
@"image": @{
|
|
|
|
@"uri": uri,
|
|
|
|
@"height": @([asset pixelHeight]),
|
|
|
|
@"width": @([asset pixelWidth]),
|
|
|
|
@"isStored": @YES, // this field doesn't seem to exist on android
|
|
|
|
@"playableDuration": @([asset duration]) // fractional seconds
|
|
|
|
},
|
|
|
|
@"timestamp": @(asset.creationDate.timeIntervalSince1970),
|
|
|
|
@"location": (loc ? @{
|
|
|
|
@"latitude": @(loc.coordinate.latitude),
|
|
|
|
@"longitude": @(loc.coordinate.longitude),
|
|
|
|
@"altitude": @(loc.altitude),
|
|
|
|
@"heading": @(loc.course),
|
|
|
|
@"speed": @(loc.speed), // speed in m/s
|
|
|
|
} : @{})
|
|
|
|
}
|
|
|
|
}];
|
2019-01-08 00:11:52 +00:00
|
|
|
}];
|
|
|
|
}];
|
2019-01-10 21:07:15 +00:00
|
|
|
|
|
|
|
// If we get this far and haven't resolved the promise yet, we reached the end of the list of photos
|
|
|
|
if (!resolvedPromise) {
|
|
|
|
hasNextPage = NO;
|
|
|
|
RCTResolvePromise(resolve, assets, hasNextPage);
|
|
|
|
resolvedPromise = YES;
|
|
|
|
}
|
|
|
|
});
|
2015-09-08 15:58:13 +00:00
|
|
|
}
|
|
|
|
|
2017-11-29 20:07:49 +00:00
|
|
|
RCT_EXPORT_METHOD(deletePhotos:(NSArray<NSString *>*)assets
|
|
|
|
resolve:(RCTPromiseResolveBlock)resolve
|
|
|
|
reject:(RCTPromiseRejectBlock)reject)
|
|
|
|
{
|
|
|
|
NSArray<NSURL *> *assets_ = [RCTConvert NSURLArray:assets];
|
|
|
|
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
|
|
|
|
PHFetchResult<PHAsset *> *fetched =
|
|
|
|
[PHAsset fetchAssetsWithALAssetURLs:assets_ options:nil];
|
|
|
|
[PHAssetChangeRequest deleteAssets:fetched];
|
|
|
|
}
|
|
|
|
completionHandler:^(BOOL success, NSError *error) {
|
|
|
|
if (success == YES) {
|
|
|
|
resolve(@(success));
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
reject(@"Couldn't delete", @"Couldn't delete assets", error);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
2016-08-18 14:16:26 +00:00
|
|
|
static void checkPhotoLibraryConfig()
|
|
|
|
{
|
|
|
|
#if RCT_DEV
|
|
|
|
if (![[NSBundle mainBundle] objectForInfoDictionaryKey:@"NSPhotoLibraryUsageDescription"]) {
|
|
|
|
RCTLogError(@"NSPhotoLibraryUsageDescription key must be present in Info.plist to use camera roll.");
|
|
|
|
}
|
|
|
|
#endif
|
|
|
|
}
|
|
|
|
|
2015-09-08 15:58:13 +00:00
|
|
|
@end
|