diff options
Diffstat (limited to 'iPhone/CordovaLib/Classes/CDVInAppBrowser.m')
-rwxr-xr-x | iPhone/CordovaLib/Classes/CDVInAppBrowser.m | 370 |
1 files changed, 314 insertions, 56 deletions
diff --git a/iPhone/CordovaLib/Classes/CDVInAppBrowser.m b/iPhone/CordovaLib/Classes/CDVInAppBrowser.m index 14671a5..b832e23 100755 --- a/iPhone/CordovaLib/Classes/CDVInAppBrowser.m +++ b/iPhone/CordovaLib/Classes/CDVInAppBrowser.m @@ -20,6 +20,7 @@ #import "CDVInAppBrowser.h" #import "CDVPluginResult.h" #import "CDVUserAgentUtil.h" +#import "CDVJSON.h" #define kInAppBrowserTargetSelf @"_self" #define kInAppBrowserTargetSystem @"_system" @@ -100,15 +101,19 @@ } } + CDVInAppBrowserOptions* browserOptions = [CDVInAppBrowserOptions parseOptions:options]; [self.inAppBrowserViewController showLocationBar:browserOptions.location]; - + [self.inAppBrowserViewController showToolBar:browserOptions.toolbar]; + if (browserOptions.closebuttoncaption != nil) { + [self.inAppBrowserViewController setCloseButtonTitle:browserOptions.closebuttoncaption]; + } // Set Presentation Style UIModalPresentationStyle presentationStyle = UIModalPresentationFullScreen; // default if (browserOptions.presentationstyle != nil) { - if ([browserOptions.presentationstyle isEqualToString:@"pagesheet"]) { + if ([[browserOptions.presentationstyle lowercaseString] isEqualToString:@"pagesheet"]) { presentationStyle = UIModalPresentationPageSheet; - } else if ([browserOptions.presentationstyle isEqualToString:@"formsheet"]) { + } else if ([[browserOptions.presentationstyle lowercaseString] isEqualToString:@"formsheet"]) { presentationStyle = UIModalPresentationFormSheet; } } @@ -117,14 +122,15 @@ // Set Transition Style UIModalTransitionStyle transitionStyle = UIModalTransitionStyleCoverVertical; // default if (browserOptions.transitionstyle != nil) { - if ([browserOptions.transitionstyle isEqualToString:@"fliphorizontal"]) { + if ([[browserOptions.transitionstyle lowercaseString] isEqualToString:@"fliphorizontal"]) { transitionStyle = UIModalTransitionStyleFlipHorizontal; - } else if ([browserOptions.transitionstyle isEqualToString:@"crossdissolve"]) { + } else if ([[browserOptions.transitionstyle lowercaseString] isEqualToString:@"crossdissolve"]) { transitionStyle = UIModalTransitionStyleCrossDissolve; } } self.inAppBrowserViewController.modalTransitionStyle = transitionStyle; + // UIWebView options self.inAppBrowserViewController.webView.scalesPageToFit = browserOptions.enableviewportscale; self.inAppBrowserViewController.webView.mediaPlaybackRequiresUserAction = browserOptions.mediaplaybackrequiresuseraction; @@ -133,13 +139,20 @@ self.inAppBrowserViewController.webView.keyboardDisplayRequiresUserAction = browserOptions.keyboarddisplayrequiresuseraction; self.inAppBrowserViewController.webView.suppressesIncrementalRendering = browserOptions.suppressesincrementalrendering; } - - if (self.viewController.modalViewController != self.inAppBrowserViewController) { + + if (! browserOptions.hidden) { + if (self.viewController.modalViewController != self.inAppBrowserViewController) { [self.viewController presentModalViewController:self.inAppBrowserViewController animated:YES]; + } } [self.inAppBrowserViewController navigateTo:url]; } +- (void)show:(CDVInvokedUrlCommand*)command +{ + [self.viewController presentModalViewController:self.inAppBrowserViewController animated:YES]; +} + - (void)openInCordovaWebView:(NSURL*)url withOptions:(NSString*)options { if ([self.commandDelegate URLIsWhitelisted:url]) { @@ -159,24 +172,162 @@ } } -#pragma mark CDVInAppBrowserNavigationDelegate +// This is a helper method for the inject{Script|Style}{Code|File} API calls, which +// provides a consistent method for injecting JavaScript code into the document. +// +// If a wrapper string is supplied, then the source string will be JSON-encoded (adding +// quotes) and wrapped using string formatting. (The wrapper string should have a single +// '%@' marker). +// +// If no wrapper is supplied, then the source string is executed directly. -- (void)browserLoadStart:(NSURL*)url +- (void)injectDeferredObject:(NSString*)source withWrapper:(NSString*)jsWrapper { - if (self.callbackId != nil) { + if (!_injectedIframeBridge) { + _injectedIframeBridge = YES; + // Create an iframe bridge in the new document to communicate with the CDVInAppBrowserViewController + [self.inAppBrowserViewController.webView stringByEvaluatingJavaScriptFromString:@"(function(d){var e = _cdvIframeBridge = d.createElement('iframe');e.style.display='none';d.body.appendChild(e);})(document)"]; + } + + if (jsWrapper != nil) { + NSString* sourceArrayString = [@[source] JSONString]; + if (sourceArrayString) { + NSString* sourceString = [sourceArrayString substringWithRange:NSMakeRange(1, [sourceArrayString length] - 2)]; + NSString* jsToInject = [NSString stringWithFormat:jsWrapper, sourceString]; + [self.inAppBrowserViewController.webView stringByEvaluatingJavaScriptFromString:jsToInject]; + } + } else { + [self.inAppBrowserViewController.webView stringByEvaluatingJavaScriptFromString:source]; + } +} + +- (void)injectScriptCode:(CDVInvokedUrlCommand*)command +{ + NSString* jsWrapper = nil; + + if ((command.callbackId != nil) && ![command.callbackId isEqualToString:@"INVALID"]) { + jsWrapper = [NSString stringWithFormat:@"_cdvIframeBridge.src='gap-iab://%@/'+window.escape(JSON.stringify([eval(%%@)]));", command.callbackId]; + } + [self injectDeferredObject:[command argumentAtIndex:0] withWrapper:jsWrapper]; +} + +- (void)injectScriptFile:(CDVInvokedUrlCommand*)command +{ + NSString* jsWrapper; + + if ((command.callbackId != nil) && ![command.callbackId isEqualToString:@"INVALID"]) { + jsWrapper = [NSString stringWithFormat:@"(function(d) { var c = d.createElement('script'); c.src = %%@; c.onload = function() { _cdvIframeBridge.src='gap-iab://%@'; }; d.body.appendChild(c); })(document)", command.callbackId]; + } else { + jsWrapper = @"(function(d) { var c = d.createElement('script'); c.src = %@; d.body.appendChild(c); })(document)"; + } + [self injectDeferredObject:[command argumentAtIndex:0] withWrapper:jsWrapper]; +} + +- (void)injectStyleCode:(CDVInvokedUrlCommand*)command +{ + NSString* jsWrapper; + + if ((command.callbackId != nil) && ![command.callbackId isEqualToString:@"INVALID"]) { + jsWrapper = [NSString stringWithFormat:@"(function(d) { var c = d.createElement('style'); c.innerHTML = %%@; c.onload = function() { _cdvIframeBridge.src='gap-iab://%@'; }; d.body.appendChild(c); })(document)", command.callbackId]; + } else { + jsWrapper = @"(function(d) { var c = d.createElement('style'); c.innerHTML = %@; d.body.appendChild(c); })(document)"; + } + [self injectDeferredObject:[command argumentAtIndex:0] withWrapper:jsWrapper]; +} + +- (void)injectStyleFile:(CDVInvokedUrlCommand*)command +{ + NSString* jsWrapper; + + if ((command.callbackId != nil) && ![command.callbackId isEqualToString:@"INVALID"]) { + jsWrapper = [NSString stringWithFormat:@"(function(d) { var c = d.createElement('link'); c.rel='stylesheet'; c.type='text/css'; c.href = %%@; c.onload = function() { _cdvIframeBridge.src='gap-iab://%@'; }; d.body.appendChild(c); })(document)", command.callbackId]; + } else { + jsWrapper = @"(function(d) { var c = d.createElement('link'); c.rel='stylesheet', c.type='text/css'; c.href = %@; d.body.appendChild(c); })(document)"; + } + [self injectDeferredObject:[command argumentAtIndex:0] withWrapper:jsWrapper]; +} + +/** + * The iframe bridge provided for the InAppBrowser is capable of executing any oustanding callback belonging + * to the InAppBrowser plugin. Care has been taken that other callbacks cannot be triggered, and that no + * other code execution is possible. + * + * To trigger the bridge, the iframe (or any other resource) should attempt to load a url of the form: + * + * gap-iab://<callbackId>/<arguments> + * + * where <callbackId> is the string id of the callback to trigger (something like "InAppBrowser0123456789") + * + * If present, the path component of the special gap-iab:// url is expected to be a URL-escaped JSON-encoded + * value to pass to the callback. [NSURL path] should take care of the URL-unescaping, and a JSON_EXCEPTION + * is returned if the JSON is invalid. + */ +- (BOOL)webView:(UIWebView*)theWebView shouldStartLoadWithRequest:(NSURLRequest*)request navigationType:(UIWebViewNavigationType)navigationType +{ + NSURL* url = request.URL; + BOOL isTopLevelNavigation = [request.URL isEqual:[request mainDocumentURL]]; + + // See if the url uses the 'gap-iab' protocol. If so, the host should be the id of a callback to execute, + // and the path, if present, should be a JSON-encoded value to pass to the callback. + if ([[url scheme] isEqualToString:@"gap-iab"]) { + NSString* scriptCallbackId = [url host]; + CDVPluginResult* pluginResult = nil; + + if ([scriptCallbackId hasPrefix:@"InAppBrowser"]) { + NSString* scriptResult = [url path]; + NSError* __autoreleasing error = nil; + + // The message should be a JSON-encoded array of the result of the script which executed. + if ((scriptResult != nil) && ([scriptResult length] > 1)) { + scriptResult = [scriptResult substringFromIndex:1]; + NSData* decodedResult = [NSJSONSerialization JSONObjectWithData:[scriptResult dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:&error]; + if ((error == nil) && [decodedResult isKindOfClass:[NSArray class]]) { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsArray:(NSArray*)decodedResult]; + } else { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_JSON_EXCEPTION]; + } + } else { + pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsArray:@[]]; + } + [self.commandDelegate sendPluginResult:pluginResult callbackId:scriptCallbackId]; + return NO; + } + } else if ((self.callbackId != nil) && isTopLevelNavigation) { + // Send a loadstart event for each top-level navigation (includes redirects). CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK - messageAsDictionary:@ {@"type":@"loadstart", @"url":[url absoluteString]}]; + messageAsDictionary:@{@"type":@"loadstart", @"url":[url absoluteString]}]; [pluginResult setKeepCallback:[NSNumber numberWithBool:YES]]; [self.commandDelegate sendPluginResult:pluginResult callbackId:self.callbackId]; } + + return YES; } -- (void)browserLoadStop:(NSURL*)url +- (void)webViewDidStartLoad:(UIWebView*)theWebView +{ + _injectedIframeBridge = NO; +} + +- (void)webViewDidFinishLoad:(UIWebView*)theWebView { if (self.callbackId != nil) { + // TODO: It would be more useful to return the URL the page is actually on (e.g. if it's been redirected). + NSString* url = [self.inAppBrowserViewController.currentURL absoluteString]; CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK - messageAsDictionary:@ {@"type":@"loadstop", @"url":[url absoluteString]}]; + messageAsDictionary:@{@"type":@"loadstop", @"url":url}]; + [pluginResult setKeepCallback:[NSNumber numberWithBool:YES]]; + + [self.commandDelegate sendPluginResult:pluginResult callbackId:self.callbackId]; + } +} + +- (void)webView:(UIWebView*)theWebView didFailLoadWithError:(NSError*)error +{ + if (self.callbackId != nil) { + NSString* url = [self.inAppBrowserViewController.currentURL absoluteString]; + CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR + messageAsDictionary:@{@"type":@"loaderror", @"url":url, @"code": [NSNumber numberWithInt:error.code], @"message": error.localizedDescription}]; [pluginResult setKeepCallback:[NSNumber numberWithBool:YES]]; [self.commandDelegate sendPluginResult:pluginResult callbackId:self.callbackId]; @@ -187,7 +338,7 @@ { if (self.callbackId != nil) { CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK - messageAsDictionary:@ {@"type":@"exit"}]; + messageAsDictionary:@{@"type":@"exit"}]; [pluginResult setKeepCallback:[NSNumber numberWithBool:YES]]; [self.commandDelegate sendPluginResult:pluginResult callbackId:self.callbackId]; @@ -203,12 +354,15 @@ @implementation CDVInAppBrowserViewController +@synthesize currentURL; + - (id)initWithUserAgent:(NSString*)userAgent prevUserAgent:(NSString*)prevUserAgent { self = [super init]; if (self != nil) { _userAgent = userAgent; _prevUserAgent = prevUserAgent; + _webViewDelegate = [[CDVWebViewDelegate alloc] initWithDelegate:self]; [self createViews]; } @@ -229,7 +383,7 @@ [self.view addSubview:self.webView]; [self.view sendSubviewToBack:self.webView]; - self.webView.delegate = self; + self.webView.delegate = _webViewDelegate; self.webView.backgroundColor = [UIColor whiteColor]; self.webView.clearsContextBeforeDrawing = YES; @@ -259,9 +413,6 @@ self.closeButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(close)]; self.closeButton.enabled = YES; - self.closeButton.imageInsets = UIEdgeInsetsZero; - self.closeButton.style = UIBarButtonItemStylePlain; - self.closeButton.width = 32.000; UIBarButtonItem* flexibleSpaceButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; @@ -325,32 +476,132 @@ [self.view addSubview:self.spinner]; } +- (void)setCloseButtonTitle:(NSString*)title +{ + // the advantage of using UIBarButtonSystemItemDone is the system will localize it for you automatically + // but, if you want to set this yourself, knock yourself out (we can't set the title for a system Done button, so we have to create a new one) + self.closeButton = nil; + self.closeButton = [[UIBarButtonItem alloc] initWithTitle:title style:UIBarButtonItemStyleBordered target:self action:@selector(close)]; + self.closeButton.enabled = YES; + self.closeButton.tintColor = [UIColor colorWithRed:60.0 / 255.0 green:136.0 / 255.0 blue:230.0 / 255.0 alpha:1]; + + NSMutableArray* items = [self.toolbar.items mutableCopy]; + [items replaceObjectAtIndex:0 withObject:self.closeButton]; + [self.toolbar setItems:items]; +} + - (void)showLocationBar:(BOOL)show { - CGRect addressLabelFrame = self.addressLabel.frame; - BOOL locationBarVisible = (addressLabelFrame.size.height > 0); + CGRect locationbarFrame = self.addressLabel.frame; + + BOOL toolbarVisible = !self.toolbar.hidden; + + // prevent double show/hide + if (show == !(self.addressLabel.hidden)) { + return; + } + + if (show) { + self.addressLabel.hidden = NO; + + if (toolbarVisible) { + // toolBar at the bottom, leave as is + // put locationBar on top of the toolBar + + CGRect webViewBounds = self.view.bounds; + webViewBounds.size.height -= FOOTER_HEIGHT; + self.webView.frame = webViewBounds; + + locationbarFrame.origin.y = webViewBounds.size.height; + self.addressLabel.frame = locationbarFrame; + } else { + // no toolBar, so put locationBar at the bottom + + CGRect webViewBounds = self.view.bounds; + webViewBounds.size.height -= LOCATIONBAR_HEIGHT; + self.webView.frame = webViewBounds; + + locationbarFrame.origin.y = webViewBounds.size.height; + self.addressLabel.frame = locationbarFrame; + } + } else { + self.addressLabel.hidden = YES; + + if (toolbarVisible) { + // locationBar is on top of toolBar, hide locationBar + + // webView take up whole height less toolBar height + CGRect webViewBounds = self.view.bounds; + webViewBounds.size.height -= TOOLBAR_HEIGHT; + self.webView.frame = webViewBounds; + } else { + // no toolBar, expand webView to screen dimensions + + CGRect webViewBounds = self.view.bounds; + self.webView.frame = webViewBounds; + } + } +} + +- (void)showToolBar:(BOOL)show +{ + CGRect toolbarFrame = self.toolbar.frame; + CGRect locationbarFrame = self.addressLabel.frame; + + BOOL locationbarVisible = !self.addressLabel.hidden; // prevent double show/hide - if (locationBarVisible == show) { + if (show == !(self.toolbar.hidden)) { return; } if (show) { - CGRect webViewBounds = self.view.bounds; - webViewBounds.size.height -= FOOTER_HEIGHT; - self.webView.frame = webViewBounds; + self.toolbar.hidden = NO; + + if (locationbarVisible) { + // locationBar at the bottom, move locationBar up + // put toolBar at the bottom + + CGRect webViewBounds = self.view.bounds; + webViewBounds.size.height -= FOOTER_HEIGHT; + self.webView.frame = webViewBounds; + + locationbarFrame.origin.y = webViewBounds.size.height; + self.addressLabel.frame = locationbarFrame; - CGRect addressLabelFrame = self.addressLabel.frame; - addressLabelFrame.size.height = LOCATIONBAR_HEIGHT; - self.addressLabel.frame = addressLabelFrame; + toolbarFrame.origin.y = (webViewBounds.size.height + LOCATIONBAR_HEIGHT); + self.toolbar.frame = toolbarFrame; + } else { + // no locationBar, so put toolBar at the bottom + + CGRect webViewBounds = self.view.bounds; + webViewBounds.size.height -= TOOLBAR_HEIGHT; + self.webView.frame = webViewBounds; + + toolbarFrame.origin.y = webViewBounds.size.height; + self.toolbar.frame = toolbarFrame; + } } else { - CGRect webViewBounds = self.view.bounds; - webViewBounds.size.height -= TOOLBAR_HEIGHT; - self.webView.frame = webViewBounds; + self.toolbar.hidden = YES; - CGRect addressLabelFrame = self.addressLabel.frame; - addressLabelFrame.size.height = 0; - self.addressLabel.frame = addressLabelFrame; + if (locationbarVisible) { + // locationBar is on top of toolBar, hide toolBar + // put locationBar at the bottom + + // webView take up whole height less locationBar height + CGRect webViewBounds = self.view.bounds; + webViewBounds.size.height -= LOCATIONBAR_HEIGHT; + self.webView.frame = webViewBounds; + + // move locationBar down + locationbarFrame.origin.y = webViewBounds.size.height; + self.addressLabel.frame = locationbarFrame; + } else { + // no locationBar, expand webView to screen dimensions + + CGRect webViewBounds = self.view.bounds; + self.webView.frame = webViewBounds; + } } } @@ -376,6 +627,8 @@ [[self parentViewController] dismissModalViewControllerAnimated:YES]; } + self.currentURL = nil; + if ((self.navigationDelegate != nil) && [self.navigationDelegate respondsToSelector:@selector(browserExit)]) { [self.navigationDelegate browserExit]; } @@ -385,16 +638,14 @@ { NSURLRequest* request = [NSURLRequest requestWithURL:url]; - _requestedURL = url; - if (_userAgentLockToken != 0) { [self.webView loadRequest:request]; } else { [CDVUserAgentUtil acquireLock:^(NSInteger lockToken) { - _userAgentLockToken = lockToken; - [CDVUserAgentUtil setUserAgent:_userAgent lockToken:lockToken]; - [self.webView loadRequest:request]; - }]; + _userAgentLockToken = lockToken; + [CDVUserAgentUtil setUserAgent:_userAgent lockToken:lockToken]; + [self.webView loadRequest:request]; + }]; } } @@ -420,20 +671,24 @@ [self.spinner startAnimating]; - if ((self.navigationDelegate != nil) && [self.navigationDelegate respondsToSelector:@selector(browserLoadStart:)]) { - NSURL* url = theWebView.request.URL; - if (url == nil) { - url = _requestedURL; - } - [self.navigationDelegate browserLoadStart:url]; + return [self.navigationDelegate webViewDidStartLoad:theWebView]; +} + +- (BOOL)webView:(UIWebView*)theWebView shouldStartLoadWithRequest:(NSURLRequest*)request navigationType:(UIWebViewNavigationType)navigationType +{ + BOOL isTopLevelNavigation = [request.URL isEqual:[request mainDocumentURL]]; + + if (isTopLevelNavigation) { + self.currentURL = request.URL; } + return [self.navigationDelegate webView:theWebView shouldStartLoadWithRequest:request navigationType:navigationType]; } - (void)webViewDidFinishLoad:(UIWebView*)theWebView { // update url, stop spinner, update back/forward - self.addressLabel.text = theWebView.request.URL.absoluteString; + self.addressLabel.text = [self.currentURL absoluteString]; self.backButton.enabled = theWebView.canGoBack; self.forwardButton.enabled = theWebView.canGoForward; @@ -450,15 +705,12 @@ // from it must pass through its white-list. This *does* break PDFs that // contain links to other remote PDF/websites. // More info at https://issues.apache.org/jira/browse/CB-2225 - BOOL isPDF = [@"true" isEqualToString:[theWebView stringByEvaluatingJavaScriptFromString:@"document.body==null"]]; + BOOL isPDF = [@"true" isEqualToString :[theWebView stringByEvaluatingJavaScriptFromString:@"document.body==null"]]; if (isPDF) { [CDVUserAgentUtil setUserAgent:_prevUserAgent lockToken:_userAgentLockToken]; } - if ((self.navigationDelegate != nil) && [self.navigationDelegate respondsToSelector:@selector(browserLoadStop:)]) { - NSURL* url = theWebView.request.URL; - [self.navigationDelegate browserLoadStop:url]; - } + [self.navigationDelegate webViewDidFinishLoad:theWebView]; } - (void)webView:(UIWebView*)theWebView didFailLoadWithError:(NSError*)error @@ -471,6 +723,8 @@ [self.spinner stopAnimating]; self.addressLabel.text = @"Load Error"; + + [self.navigationDelegate webView:theWebView didFailLoadWithError:error]; } #pragma mark CDVScreenOrientationDelegate @@ -510,12 +764,15 @@ if (self = [super init]) { // default values self.location = YES; + self.toolbar = YES; + self.closebuttoncaption = nil; self.enableviewportscale = NO; self.mediaplaybackrequiresuseraction = NO; self.allowinlinemediaplayback = NO; self.keyboarddisplayrequiresuseraction = YES; self.suppressesincrementalrendering = NO; + self.hidden = NO; } return self; @@ -534,19 +791,20 @@ if ([keyvalue count] == 2) { NSString* key = [[keyvalue objectAtIndex:0] lowercaseString]; - NSString* value = [[keyvalue objectAtIndex:1] lowercaseString]; + NSString* value = [keyvalue objectAtIndex:1]; + NSString* value_lc = [value lowercaseString]; - BOOL isBoolean = [value isEqualToString:@"yes"] || [value isEqualToString:@"no"]; + BOOL isBoolean = [value_lc isEqualToString:@"yes"] || [value_lc isEqualToString:@"no"]; NSNumberFormatter* numberFormatter = [[NSNumberFormatter alloc] init]; [numberFormatter setAllowsFloats:YES]; - BOOL isNumber = [numberFormatter numberFromString:value] != nil; + BOOL isNumber = [numberFormatter numberFromString:value_lc] != nil; // set the property according to the key name if ([obj respondsToSelector:NSSelectorFromString(key)]) { if (isNumber) { - [obj setValue:[numberFormatter numberFromString:value] forKey:key]; + [obj setValue:[numberFormatter numberFromString:value_lc] forKey:key]; } else if (isBoolean) { - [obj setValue:[NSNumber numberWithBool:[value isEqualToString:@"yes"]] forKey:key]; + [obj setValue:[NSNumber numberWithBool:[value_lc isEqualToString:@"yes"]] forKey:key]; } else { [obj setValue:value forKey:key]; } |