cordova-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From agri...@apache.org
Subject git commit: UrlRemap: Add allowFurtherRemapping flag to aliases to (dis)allow chaining.
Date Fri, 07 Mar 2014 20:57:13 GMT
Repository: cordova-app-harness
Updated Branches:
  refs/heads/master 3e22a6ffc -> 8950c4364


UrlRemap: Add allowFurtherRemapping flag to aliases to (dis)allow chaining.

Also re-wrote iOS size considerably.


Project: http://git-wip-us.apache.org/repos/asf/cordova-app-harness/repo
Commit: http://git-wip-us.apache.org/repos/asf/cordova-app-harness/commit/8950c436
Tree: http://git-wip-us.apache.org/repos/asf/cordova-app-harness/tree/8950c436
Diff: http://git-wip-us.apache.org/repos/asf/cordova-app-harness/diff/8950c436

Branch: refs/heads/master
Commit: 8950c4364a25cea04b4bfb9330fb696943d94834
Parents: 3e22a6f
Author: Andrew Grieve <agrieve@chromium.org>
Authored: Fri Mar 7 14:57:42 2014 -0500
Committer: Andrew Grieve <agrieve@chromium.org>
Committed: Fri Mar 7 15:52:38 2014 -0500

----------------------------------------------------------------------
 UrlRemap/src/android/UrlRemap.java |  12 +-
 UrlRemap/src/ios/UrlRemap.m        | 292 ++++++++++++++++++++++++--------
 UrlRemap/urlremap.js               |   6 +-
 3 files changed, 234 insertions(+), 76 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/cordova-app-harness/blob/8950c436/UrlRemap/src/android/UrlRemap.java
----------------------------------------------------------------------
diff --git a/UrlRemap/src/android/UrlRemap.java b/UrlRemap/src/android/UrlRemap.java
index fe49b7e..28fdc1e 100644
--- a/UrlRemap/src/android/UrlRemap.java
+++ b/UrlRemap/src/android/UrlRemap.java
@@ -35,6 +35,7 @@ public class UrlRemap extends CordovaPlugin {
         Pattern replaceRegex;
         String replacer;
         boolean redirectToReplacedUrl;
+        boolean allowFurtherRemapping;
         String jsToInject;
     }
 
@@ -50,6 +51,7 @@ public class UrlRemap extends CordovaPlugin {
             params.replaceRegex = Pattern.compile(args.getString(1));
             params.replacer = args.getString(2);
             params.redirectToReplacedUrl = args.getBoolean(3);
+            params.allowFurtherRemapping = args.getBoolean(4);
             rerouteParams.add(params);
         } else if ("clearAllAliases".equals(action)) {
             resetMappings();
@@ -81,7 +83,7 @@ public class UrlRemap extends CordovaPlugin {
         }
         return null;
     }
-    
+
     @Override
     public boolean onOverrideUrlLoading(String url) {
         if (resetUrlParams != null && resetUrlParams.matchRegex.matcher(url).find())
{
@@ -96,7 +98,7 @@ public class UrlRemap extends CordovaPlugin {
             if (resetUrlParams != null && resetUrlParams.matchRegex.matcher(newUrl).find())
{
                 resetMappings();
             }
-            
+
             webView.loadUrl(newUrl);
             return true;
         }
@@ -122,7 +124,11 @@ public class UrlRemap extends CordovaPlugin {
         RouteParams params = getChosenParams(uriAsString, false);
         if (params != null && !params.redirectToReplacedUrl) {
             String newUrl = params.replaceRegex.matcher(uriAsString).replaceFirst(params.replacer);
-            return Uri.parse(newUrl);
+            Uri ret = Uri.parse(newUrl);
+            if (params.allowFurtherRemapping) {
+                ret = webView.getResourceApi().remapUri(ret);
+            }
+            return ret;
         }
         return null;
     }

http://git-wip-us.apache.org/repos/asf/cordova-app-harness/blob/8950c436/UrlRemap/src/ios/UrlRemap.m
----------------------------------------------------------------------
diff --git a/UrlRemap/src/ios/UrlRemap.m b/UrlRemap/src/ios/UrlRemap.m
index 995a898..e9f0e3d 100644
--- a/UrlRemap/src/ios/UrlRemap.m
+++ b/UrlRemap/src/ios/UrlRemap.m
@@ -6,9 +6,9 @@
  to you 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
@@ -17,21 +17,48 @@
  under the License.
  */
 #import <UIKit/UIKit.h>
+#import <MobileCoreServices/MobileCoreServices.h>
 #import <Cordova/CDVPlugin.h>
 
-@class RouteParams;
+// Tricky things:
+// 1. iOS 6+ gives "Frame load interrupted" when you try to handle a top-frame load with
a NSURLProtocol.
+//   http://stackoverflow.com/questions/12058203/using-a-custom-nsurlprotocol-on-ios-for-file-urls-causes-frame-load-interrup/19432303/
+//   To work around this, we detect the nav in shouldOverrideLoadWithRequest and change it
into a [UIWebView loadData] call,
+// 2. iOS 6+ also has issues with using a NSURLProtocol to load iframes.
+//   To work around this, we do a 302 redirect instead.
 
-static UIWebView* gWebView = nil;
-static NSMutableArray* gRerouteParams = nil;
+
+@class RouteParams;
+@class UrlRemap;
+
+static UrlRemap* gPlugin = nil;
+
+static NSString* mimeTypeForPath(NSString* path) {
+    NSString *ret = nil;
+    CFStringRef pathExtension = (__bridge_retained CFStringRef)[path pathExtension];
+    CFStringRef type = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension,
pathExtension, NULL);
+    CFRelease(pathExtension);
+    if (type != NULL) {
+        ret = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass(type, kUTTagClassMIMEType);
+        CFRelease(type);
+    }
+    return ret;
+}
 
 @interface UrlRemap : CDVPlugin {
-  RouteParams* _resetUrlParams;
+  @package
+    RouteParams* _resetUrlParams;
+    BOOL _giveFreePassToNextLoad;
+    NSMutableArray* _rerouteParams;
+    NSMutableSet* _frameUris;
 }
+
 - (void)addAlias:(CDVInvokedUrlCommand*)command;
 - (void)clearAllAliases:(CDVInvokedUrlCommand*)command;
 @end
 
-@interface UrlRemap() {}
+@interface UrlRemap()
+- (RouteParams*)getChosenParams:(NSURL*)url forInjection:(BOOL)forInjection;
 @end
 
 @interface RouteParams : NSObject {
@@ -40,6 +67,7 @@ static NSMutableArray* gRerouteParams = nil;
     NSRegularExpression* _replaceRegex;
     NSString* _replacer;
     BOOL _redirectToReplacedUrl;
+    BOOL _allowFurtherRemapping;
     NSString* _jsToInject;
 }
 @end
@@ -50,10 +78,20 @@ static NSMutableArray* gRerouteParams = nil;
     NSInteger numMatches = [_matchRegex numberOfMatchesInString:uriString options:0 range:wholeStringRange];
     return numMatches > 0;
 }
+
+- (NSURL*)applyReplacement:(NSURL*)src {
+    NSString* urlString = [src absoluteString];
+    NSRange wholeStringRange = NSMakeRange(0, [urlString length]);
+    NSString* newUrlString = [_replaceRegex stringByReplacingMatchesInString:urlString options:0
range:wholeStringRange withTemplate:_replacer];
+    NSURL* newUrl = [NSURL URLWithString:newUrlString];
+    return newUrl;
+}
+
 @end
 
-@interface UrlRemapURLProtocol : NSURLProtocol
-+ (RouteParams*)getChosenParams:(NSString*)uriString forInjection:(BOOL)forInjection;
+@interface UrlRemapURLProtocol : NSURLProtocol {
+    NSURLConnection* _activeConnection;
+}
 @end
 
 
@@ -61,28 +99,76 @@ static NSMutableArray* gRerouteParams = nil;
 
 @implementation UrlRemap
 
+- (RouteParams*)getChosenParams:(NSURL*)url forInjection:(BOOL)forInjection {
+    NSString* uriString = [url absoluteString];
+    for (RouteParams* param in _rerouteParams) {
+        if (forInjection != !!param->_jsToInject) {
+            continue;
+        }
+        if ([param matches:uriString]) {
+            return param;
+        }
+    }
+    return nil;
+}
+
 - (void)pluginInitialize {
     [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(pageDidLoad)
name:CDVPageDidLoadNotification object:self.webView];
-    gWebView = self.webView;
+    gPlugin = self;
     [NSURLProtocol registerClass:[UrlRemapURLProtocol class]];
-    gRerouteParams = [[NSMutableArray alloc] init];
+    _rerouteParams = [[NSMutableArray alloc] init];
+    _frameUris = [[NSMutableSet alloc] init];
 }
 
 - (void)pageDidLoad {
-    NSString* url = [self.webView stringByEvaluatingJavaScriptFromString:@"location.href.replace(/#.*/,
'')"];
-    RouteParams* params = [UrlRemapURLProtocol getChosenParams:url forInjection:YES];
-    if (params != nil) {
-        [gWebView stringByEvaluatingJavaScriptFromString:params->_jsToInject];
+    @synchronized (self) {
+        NSString* url = [self.webView stringByEvaluatingJavaScriptFromString:@"location.href.replace(/#.*/,
'')"];
+        RouteParams* params = [self getChosenParams:[NSURL URLWithString:url] forInjection:YES];
+        if (params != nil) {
+            [self.webView stringByEvaluatingJavaScriptFromString:params->_jsToInject];
+        }
     }
 }
 
 - (BOOL)shouldOverrideLoadWithRequest:(NSURLRequest*)request navigationType:(UIWebViewNavigationType)navigationType
{
-    BOOL isTopLevelNavigation = [request.URL isEqual:request.mainDocumentURL];
-    if (_resetUrlParams != nil && isTopLevelNavigation) {
-        if ([_resetUrlParams matches:request.URL.absoluteString]) {
-            [gRerouteParams removeAllObjects];
+    NSURL* url = [request URL];
+    BOOL isTopLevelNavigation = [url isEqual:request.mainDocumentURL];
+    if (isTopLevelNavigation) {
+        RouteParams* params = nil;
+        @synchronized (self) {
+            // Prevents infinite loop from the loadData call below.
+            if (_giveFreePassToNextLoad) {
+                _giveFreePassToNextLoad = NO;
+                return NO;
+            }
+            if (_resetUrlParams != nil) {
+                if ([_resetUrlParams matches:[url absoluteString]]) {
+                    [_rerouteParams removeAllObjects];
+                }
+            }
+            params = [self getChosenParams:url forInjection:NO];
+        }
+        // For top-level navigations where we need to do a sub-resource load.
+        if (params != nil) {
+            NSURL* newUrl = [params applyReplacement:url];
+            // Note: Using loadData: clears the browser history stack. e.g. history.back()
doesn't work.
+            NSData* body = nil;
+            if (params->_allowFurtherRemapping) {
+                body = [NSData dataWithContentsOfURL:newUrl];
+            } else {
+                body = [NSData dataWithContentsOfFile:[newUrl path]];
+            }
+            _giveFreePassToNextLoad = YES;
+            [self.webView loadData:body MIMEType:@"text/html" textEncodingName:@"utf8" baseURL:url];
+            return YES;
+        }
+    } else {
+        RouteParams* params = [self getChosenParams:url forInjection:NO];
+        if (params != nil) {
+            [_frameUris addObject:url];
         }
     }
+
     return NO;
 }
 
@@ -106,7 +192,10 @@ static NSMutableArray* gRerouteParams = nil;
             params->_replaceRegex = sourceUrlReplaceRegex;
             params->_replacer = replaceString;
             params->_redirectToReplacedUrl = [[command argumentAtIndex:3] boolValue];
-            [gRerouteParams addObject:params];
+            params->_allowFurtherRemapping = [[command argumentAtIndex:4] boolValue];
+            @synchronized (self) {
+                [_rerouteParams addObject:params];
+            }
             pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK];
         }
     }
@@ -114,7 +203,9 @@ static NSMutableArray* gRerouteParams = nil;
 }
 
 - (void)clearAllAliases:(CDVInvokedUrlCommand*)command {
-    [gRerouteParams removeAllObjects];
+    @synchronized (self) {
+        [_rerouteParams removeAllObjects];
+    }
     CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK];
     [self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
 }
@@ -123,7 +214,9 @@ static NSMutableArray* gRerouteParams = nil;
     RouteParams* params = [[RouteParams alloc] init];
     params->_matchRegex = [NSRegularExpression regularExpressionWithPattern:[command argumentAtIndex:0]
options:0 error:nil];
     params->_jsToInject = [command argumentAtIndex:1];
-    [gRerouteParams addObject:params];
+    @synchronized (self) {
+        [_rerouteParams addObject:params];
+    }
     [self.commandDelegate sendPluginResult:[CDVPluginResult resultWithStatus:CDVCommandStatus_OK]
callbackId:command.callbackId];
 }
 
@@ -139,23 +232,15 @@ static NSMutableArray* gRerouteParams = nil;
 
 @implementation UrlRemapURLProtocol
 
-+ (RouteParams*)getChosenParams:(NSString*)uriString forInjection:(BOOL)forInjection {
-    for (RouteParams* param in gRerouteParams) {
-        if (forInjection != !!param->_jsToInject) {
-            continue;
-        }
-        if ([param matches:uriString]) {
-            return param;
-        }
-    }
-    return nil;
-}
-
 + (BOOL)canInitWithRequest:(NSURLRequest*)request {
+    if ([request valueForHTTPHeaderField:@"fo"] != nil) {
+        return NO;
+    }
     NSURL* url = [request URL];
-    NSString* urlString = [url absoluteString];
-    RouteParams* params = [UrlRemapURLProtocol getChosenParams:urlString forInjection:NO];
-    return params != nil;
+    @synchronized (gPlugin) {
+        RouteParams* params = [gPlugin getChosenParams:url forInjection:NO];
+        return params != nil;
+    }
 }
 
 + (NSURLRequest*)canonicalRequestForRequest:(NSURLRequest*)request {
@@ -164,17 +249,40 @@ static NSMutableArray* gRerouteParams = nil;
 
 - (void)issueNotFoundResponse {
     NSURL* url = [[self request] URL];
-    NSURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:url statusCode:404 HTTPVersion:@"HTTP/1.1"
headerFields:@{ @"Access-Control-Allow-Origin": @"*" }];
+    NSURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:url statusCode:404 HTTPVersion:@"HTTP/1.1"
headerFields:@{}];
     [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
     [[self client] URLProtocolDidFinishLoading:self];
 }
 
-- (void)issueNSURLResponseForUrl:(NSURL*)url origUrl:(NSURL*)origUrl {
+- (void)issueRedirectToURL:(NSURL*)dest {
+    NSMutableURLRequest* req = [[self request] mutableCopy];
+    [req setURL:dest];
+    [req setValue:@"FOO" forHTTPHeaderField:@"fo"];
+
+    NSURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:[[self request] URL]
statusCode:302 HTTPVersion:@"HTTP/1.1" headerFields:@{ @"Location": [dest absoluteString]
}];
+
+    [[self client] URLProtocol:self wasRedirectedToRequest:req redirectResponse:response];
+    //[[self client] URLProtocolDidFinishLoading:self];
+}
+
+- (void)issueDirectResponseForFileUrl:(NSURL*)url {
+    NSURL* origUrl = [self.request URL];
     if ([[url scheme] isEqualToString:@"file"]) {
         NSString* path = [url path];
         FILE* fp = fopen([path UTF8String], "r");
         if (fp) {
-            NSURLResponse *response = [[NSURLResponse alloc] initWithURL:origUrl MIMEType:@"text/html"
expectedContentLength:-1 textEncodingName:@"utf8"];
+            fseek(fp, 0L, SEEK_END);
+            long contentLength = ftell(fp);
+            fseek(fp, 0L, SEEK_SET);
+
+            NSMutableDictionary* responseHeaders = [[NSMutableDictionary alloc] init];
+            responseHeaders[@"Cache-Control"] = @"no-cache";
+            responseHeaders[@"Content-Length"] = [[NSNumber numberWithLong:contentLength]
stringValue];
+            NSString* mimeType = mimeTypeForPath(path);
+            if (mimeType != nil) {
+                responseHeaders[@"Content-Type"] = mimeType;
+            }
+            NSURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:origUrl statusCode:200
HTTPVersion:@"HTTP/1.1" headerFields:responseHeaders];
             [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
 
             char* buf = malloc(32768);
@@ -195,44 +303,88 @@ static NSMutableArray* gRerouteParams = nil;
     }
 }
 
-- (void)issueRedirectResponseForUrl:(NSURL*)url {
-    if([gWebView isLoading]) {
-        [gWebView stopLoading];
-    }
-    NSURLRequest *request = [NSURLRequest requestWithURL:url];
-    [gWebView loadRequest:request];
-}
-
-- (void)issueTopLevelRedirect:(NSURL*)url origURL:(NSURL*)origURL {
-    [gWebView stopLoading];
-    // BUG: Using loadData: clears the browser history stack. e.g. history.back() doesn't
work.
-    [gWebView loadData:[NSData dataWithContentsOfURL:url] MIMEType:@"text/html" textEncodingName:@"utf8"
baseURL:origURL];
+- (void)doLoadURL:(NSURL*)url {
+    NSMutableURLRequest* req = [[self request] mutableCopy];
+    [req setURL:url];
+    _activeConnection = [[NSURLConnection alloc] initWithRequest:req delegate:self];
 }
 
 - (void)startLoading {
     NSURLRequest* request = [self request];
-    NSString* urlString = [[request URL] absoluteString];
+    int action = 0;
+    NSURL* newUrl = nil;
 
-    RouteParams* params = [UrlRemapURLProtocol getChosenParams:urlString forInjection:NO];
-    NSRange wholeStringRange = NSMakeRange(0, [urlString length]);
-    NSString* newUrlString = [params->_replaceRegex stringByReplacingMatchesInString:urlString
options:0 range:wholeStringRange withTemplate:params->_replacer];
-    NSURL* newUrl = [NSURL URLWithString:newUrlString];
+    @synchronized (gPlugin) {
+        RouteParams* params = [gPlugin getChosenParams:[request URL] forInjection:NO];
 
-    BOOL isTopLevelNavigation = [request.URL isEqual:request.mainDocumentURL];
-    
-    // iOS 6+ just gives "Frame load interrupted" when you try and feed it data via a URLProtocol.
-    // http://stackoverflow.com/questions/12058203/using-a-custom-nsurlprotocol-on-ios-for-file-urls-causes-frame-load-interrup/19432303
-    NSURL* pageUrl = params->_redirectToReplacedUrl ? newUrl : [request URL];
-    if (isTopLevelNavigation) {
-        [self issueTopLevelRedirect:newUrl origURL:pageUrl];
-    } else if(params->_redirectToReplacedUrl) {
-        [self issueRedirectResponseForUrl:newUrl];
-    } else {
-        [self issueNSURLResponseForUrl:newUrl origUrl:pageUrl];
+        // Race condition where params are cleared between canInit and startLoading.
+        if (params == nil) {
+            [self issueNotFoundResponse];
+            return;
+        }
+        newUrl = [params applyReplacement:[request URL]];
+
+        BOOL isTopLevelNavigation = [request.URL isEqual:request.mainDocumentURL];
+
+        if (isTopLevelNavigation) {
+            NSLog(@"Uh oh! Unexpected Top-Level Resource Request in UrlRemap.");
+        } else if (params->_redirectToReplacedUrl) {
+            // Note: we could support this, but Android can't.
+            NSLog(@"Uh oh! UrlRemap doesn't currently support redirectToReplacedUrl (plus
these should be top-level navs).");
+        } else if ([gPlugin->_frameUris containsObject:[request URL]]) {
+            // Frame loads must use redirects for iOS to be happy.
+            int count = 0;
+            // This further remapping doesn't play well with extern NSURLProtocols. Hopefully
that's okay.
+            while (params != nil && params->_allowFurtherRemapping) {
+                params = [gPlugin getChosenParams:newUrl forInjection:NO];
+                if (params != nil) {
+                    newUrl = [params applyReplacement:newUrl];
+                }
+                if (++count > 10) {
+                    NSLog(@"Hit infinite redirect in UrlRemap!");
+                    break;
+                }
+            }
+            action = 1;
+        } else if (params->_allowFurtherRemapping) {
+            action = 2;
+        } else {
+            action = 3;
+        }
     }
+    switch (action) {
+        case 1: [self issueRedirectToURL:newUrl]; break;
+        case 2: [self doLoadURL:newUrl]; break;
+        case 3: [self issueDirectResponseForFileUrl:newUrl]; break;
+    }
+}
+
+- (void)stopLoading {
+    [_activeConnection cancel];
+}
+
+- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
{
+    // NOTE: response's URL is wrong here since it's the actual URL's response. Doesn't seem
to hurt for now...
+    NSURLResponse* resp = [[NSURLResponse alloc] initWithURL:[self.request URL] MIMEType:[response
MIMEType] expectedContentLength:[response expectedContentLength] textEncodingName:[response
textEncodingName]];
+    [[self client] URLProtocol:self didReceiveResponse:resp cacheStoragePolicy:NSURLCacheStorageNotAllowed];
 }
 
-- (void)stopLoading
-{}
+- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
+    [[self client] URLProtocol:self didLoadData:data];
+}
+
+- (NSCachedURLResponse *)connection:(NSURLConnection *)connection
+                  willCacheResponse:(NSCachedURLResponse*)cachedResponse {
+    return nil;
+}
+
+- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
+    [[self client] URLProtocolDidFinishLoading:self];
+}
+
+- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
+    [[self client] URLProtocol:self didFailWithError:error];
+}
 
 @end
+

http://git-wip-us.apache.org/repos/asf/cordova-app-harness/blob/8950c436/UrlRemap/urlremap.js
----------------------------------------------------------------------
diff --git a/UrlRemap/urlremap.js b/UrlRemap/urlremap.js
index cd791c9..88894ef 100644
--- a/UrlRemap/urlremap.js
+++ b/UrlRemap/urlremap.js
@@ -19,15 +19,15 @@
 */
 var exec = cordova.require('cordova/exec');
 
-exports.addAlias = function(sourceUriMatchRegex, sourceUriReplaceRegex, replaceString, redirectToReplacedUrl,
callback) {
+exports.addAlias = function(sourceUriMatchRegex, sourceUriReplaceRegex, replaceString, redirectToReplacedUrl,
allowFurtherRemapping, callback) {
     var win = callback && function() {
         callback(true);
     };
     var fail = callback && function(error) {
-        console.error("UrlRemap error: " + error);
+        console.error('UrlRemap error: ' + error);
         callback(false);
     };
-    exec(win, fail, 'UrlRemap', 'addAlias', [sourceUriMatchRegex, sourceUriReplaceRegex,
replaceString, redirectToReplacedUrl]);
+    exec(win, fail, 'UrlRemap', 'addAlias', [sourceUriMatchRegex, sourceUriReplaceRegex,
replaceString, redirectToReplacedUrl, allowFurtherRemapping]);
 };
 
 exports.setResetUrl = function(urlRegex, callback) {


Mime
View raw message