Enhancing the React Native Webview (Part 1) – Supporting File Uploads in iOS & Android

React Native is a really popular mobile app development framework. However when running a web application using the React Native Webview, i noticed that it lacked several essential features. To enhance the React Native Webview to support the missing essential features (as of version 0.52) i will be starting a series of blog posts which would ultimately help the React Native developers out there .

In part 1 of this series we will be looking on how to add support for file uploads for both  iOS & Android. This means enabling support for </input type=file(Browse button) to upload a file by selecting from the gallery or by taking a photo using the camera.

Supporting File Uploads in iOS

To enable support for file uploads in iOS you just have to add the NSPhotoLibraryUsageDescription key and a description explaining why your app needs photo library access, into your info.plist file.

Supporting File Uploads in Android

To enable support for file uploads in Android, it’s not easy as for iOS. You will need to write native android code to provide the support.

First we need to create a native UI component which extends the React Native Webview UI component. For more details on creating native UI components please refer the official docs: https://facebook.github.io/react-native/docs/native-components-android.html

To begin create a folder named advancedwebview  inside android -> app -> src -> main -> java -> com -> yourpackage (These names are used for this tutorial and you are free to name them however you like). Inside the folder you created create a java file named AdvancedWebView.java and add the following code.

package com.yourpackage.advancedwebview;

import android.Manifest;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebView;

import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.views.webview.ReactWebViewManager;
import com.yourpackage.MainActivity;
import com.yourpackage.R;

import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;

import static android.content.ContentValues.TAG;

public class AdvancedWebViewManager extends ReactWebViewManager {

    private String mCapturedImage;
    private ValueCallback mUploadMessage;
    private ValueCallback<Uri[]> mUploadMessageA;
    private final static int FILECHOOSER_RESULTCODE =1;
    private static final int MY_PERMISSIONS_REQUEST_ALL = 2;
    private String[] permissions = {Manifest.permission.CAMERA,Manifest.permission.WRITE_EXTERNAL_STORAGE};
    public WebView webview = null;

    private Activity mActivity = null;
    private CustomWebviewPackage aPackage;

    public String getName() {

        return "AdvancedWebView";
    }

    @ReactProp(name = "enabledUploadAndroid")
    public void enabledUploadAndroid(WebView view, boolean enabled) {
        if(enabled) {
            webview = view;
            final CustomWebviewModule module = this.aPackage.getModule();

            view.setWebChromeClient(new WebChromeClient(){

                //For Android 3.0+
                public void openFileChooser(ValueCallback uploadMsg){
                    ((MainActivity)module.getActivity()).setUploadMessage(uploadMsg);
                    if (grantPermissions()) {
                        mUploadMessage = uploadMsg;
                        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
                        i.addCategory(Intent.CATEGORY_OPENABLE);
                        i.setType("*/*");
                        module.getActivity().startActivityForResult(Intent.createChooser(i, "File Chooser"), FILECHOOSER_RESULTCODE );
                    }
                }
                // For Android 3.0+, above method not supported in some android 3+ versions, in such case we use this
                public void openFileChooser(ValueCallback uploadMsg, String acceptType){
                    ((MainActivity)module.getActivity()).setUploadMessage(uploadMsg);
                    if (grantPermissions()) {
                        mUploadMessage = uploadMsg;
                        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
                        i.addCategory(Intent.CATEGORY_OPENABLE);
                        i.setType("*/*");
                        module.getActivity().startActivityForResult(
                                Intent.createChooser(i, "File Browser"),
                                FILECHOOSER_RESULTCODE );
                    }
                }
                //For Android 4.1+
                public void openFileChooser(ValueCallback uploadMsg, String acceptType, String capture){
                    ((MainActivity)module.getActivity()).setUploadMessage(uploadMsg);
                    if (grantPermissions()) {
                        mUploadMessage = uploadMsg;
                        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
                        i.addCategory(Intent.CATEGORY_OPENABLE);
                        i.setType("*/*");
                        module.getActivity().startActivityForResult(Intent.createChooser(i, "File Chooser"), FILECHOOSER_RESULTCODE );
                    }
                }
                //For Android 5.0+
                public boolean onShowFileChooser(
                        WebView webView, ValueCallback<Uri[]> filePathCallback,
                        WebChromeClient.FileChooserParams fileChooserParams) {
                    ((MainActivity) module.getActivity()).setmUploadCallbackAboveL(filePathCallback);

                    if (grantPermissions()) {

                        mUploadMessageA = filePathCallback;
                        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
                        if (takePictureIntent.resolveActivity(module.getActivity().getPackageManager()) != null) {
                            File photoFile = null;
                            try {
                                photoFile = createImageFile();
                                takePictureIntent.putExtra("PhotoPath", mCapturedImage);
                            } catch (IOException ex) {
                                Log.e(TAG, "Image file creation failed", ex);
                            }
                            if (photoFile != null) {
                                mCapturedImage = "file:" + photoFile.getAbsolutePath();
                                ((MainActivity) module.getActivity()).setMCapturedImage(mCapturedImage);
                                takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(photoFile));
                            } else {
                                takePictureIntent = null;
                            }
                        }
                        Intent contentSelectionIntent = new Intent(Intent.ACTION_GET_CONTENT);
                        contentSelectionIntent.addCategory(Intent.CATEGORY_OPENABLE);
                        contentSelectionIntent.setType("*/*");
                        Intent[] intentArray;
                        if (takePictureIntent != null) {
                            intentArray = new Intent[]{takePictureIntent};
                        } else {
                            intentArray = new Intent[0];
                        }

                        Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
                        chooserIntent.putExtra(Intent.EXTRA_INTENT, contentSelectionIntent);
                        chooserIntent.putExtra(Intent.EXTRA_TITLE, "Image Chooser");
                        chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentArray);
                        module.getActivity().startActivityForResult(chooserIntent, FILECHOOSER_RESULTCODE );
                        return true;
                    }else{
                        return false;
                    }
                }
            });

        }
    }

    // Create an image file
    private File createImageFile() throws IOException{
        @SuppressLint("SimpleDateFormat") String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
        String imageFileName = "img_"+timeStamp+"_";
        File storageDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
        return File.createTempFile(imageFileName,".jpg",storageDir);
    }
   //Providing run-time permissions
    private boolean grantPermissions() {
        if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M){
            return true;
        }
        boolean result = true;
        final CustomWebviewModule module = this.aPackage.getModule();
        for (String permission:permissions){
            if (ContextCompat.checkSelfPermission(module.getActivity(),
                    permission)
                    != PackageManager.PERMISSION_GRANTED) {
                result = false;
            }

        }

        if(!result){
            ActivityCompat.requestPermissions(module.getActivity(),
                    permissions,
                    MY_PERMISSIONS_REQUEST_ALL);
        }
        return result;
    }


    public void setPackage(CustomWebviewPackage aPackage){
        this.aPackage = aPackage;
    }

    public CustomWebviewPackage getPackage(){
        return this.aPackage;
    }
}

enabledUploadAndroid is a prop passed from the Javascript side. When passed true enabledUploadAndroid method will be called and WebChromeClient which has filechooser methods which will fire when </input type=file is clicked will be set for the webview.

The following permissions should be added to the AndroidManifest.xml to allow the app to access camera and the internal storage.

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA"/>

Starting from Android 6.0 (API level 23), we also need to request for permission at run time. The grantPermissions method in the above code is used to acquire the required permission during run time.

Now we need to register the above created View Manager to let android know that it exists. For that first we need to create a class which extends ReactPackage. Inside android -> app -> src -> main -> java -> com -> yourpackage ->advancedwebview  create a file named AdvancedWebviewPackage.java and add the following code to it.

package com.yourpackage.advancedwebview;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.JavaScriptModule;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class AdvancedWebviewPackage implements ReactPackage {
    private AdvancedWebviewManager manager;
    private AdvancedWebviewModule module;

    public List<Class<? extends JavaScriptModule>> createJSModules() {
        return Collections.emptyList();
    }

    @Override public List createViewManagers(ReactApplicationContext reactContext) {
        manager = new AdvancedWebviewManager();
        manager.setPackage(this);
        return Arrays.<ViewManager>asList(manager);
    }

    @Override public List createNativeModules( ReactApplicationContext reactContext) {
        List modules = new ArrayList<>();
        return modules;
    }

    public AdvancedWebviewManager getManager(){
        return manager;
    }

    public AdvancedWebviewModule getModule(){
        return module;
    }
}

View manager registrations happens inside the createViewManagers methodStill our main android application is not aware about our newly created package class. To let it know we need to add it to our application’s android -> app -> src -> main -> java -> com -> yourpackage ->MainApplication.java file’s getPackages() method as shown below.

package com.yourpackage;

import android.app.Application;

import com.facebook.react.ReactApplication;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;
import com.facebook.soloader.SoLoader;
import com.yourpackage.advancedwebview.AdvancedWebviewPackage;
import java.util.Arrays;
import java.util.List;

public class MainApplication extends Application implements ReactApplication {

  private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
    @Override
    public boolean getUseDeveloperSupport() {
      return BuildConfig.DEBUG;
    }

    @Override
    protected List getPackages() {
      return Arrays.<ReactPackage>asList(
          new MainReactPackage(),
            new AdvancedWebviewPackage() //add this
      );
    }

    @Override
    protected String getJSMainModuleName() {
      return "index";
    }
  };

  @Override
  public ReactNativeHost getReactNativeHost() {
    return mReactNativeHost;
  }

  @Override
  public void onCreate() {
    super.onCreate();
    SoLoader.init(this, /* native exopackage */ false);
  }
}

After writing the required native code we need to access it from the react native side. For that create a file named AdvancedWebview.android.js outside the android folder (With your other react native JS files) and add the following code to it. This code was taken from https://github.com/facebook/react-native/blob/master/Libraries/Components/WebView/WebView.android.js and was modified according to our need. Added enabledUploadAndroid proptype to it which is required to enable the native code which we discussed above.

/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 *
 * 
 */
'use strict';

var EdgeInsetsPropType = require('EdgeInsetsPropType');
var ActivityIndicator = require('ActivityIndicator');
var React = require('React');
var PropTypes = require('prop-types');
var ReactNative = require('ReactNative');
var StyleSheet = require('StyleSheet');
var UIManager = require('UIManager');
var View = require('View');
var ViewPropTypes = require('ViewPropTypes');

var deprecatedPropType = require('deprecatedPropType');
var keyMirror = require('fbjs/lib/keyMirror');
var requireNativeComponent = require('requireNativeComponent');
var resolveAssetSource = require('resolveAssetSource');

var ADVANCED_WEBVIEW_REF = 'advancedwebview';

var WebViewState = keyMirror({
  IDLE: null,
  LOADING: null,
  ERROR: null,
});

var defaultRenderLoading = () => (
  <View style={styles.loadingView}>
    <ActivityIndicator
      style={styles.loadingProgressBar}
    />
  </View>
);

/**
 * Renders a native WebView.
 */
class AdvancedWebView extends React.Component {
  static get extraNativeComponentConfig() {
    return {
      nativeOnly: {
        messagingEnabled: PropTypes.bool,
      },
    };
  }

  static propTypes = {
    ...ViewPropTypes,
    renderError: PropTypes.func,
    renderLoading: PropTypes.func,
    onLoad: PropTypes.func,
    onLoadEnd: PropTypes.func,
    onLoadStart: PropTypes.func,
    onError: PropTypes.func,
    automaticallyAdjustContentInsets: PropTypes.bool,
    contentInset: EdgeInsetsPropType,
    onNavigationStateChange: PropTypes.func,
    onMessage: PropTypes.func,
    onContentSizeChange: PropTypes.func,
    startInLoadingState: PropTypes.bool, // force WebView to show loadingView on first load
    style: ViewPropTypes.style,

    html: deprecatedPropType(
      PropTypes.string,
      'Use the `source` prop instead.'
    ),

    url: deprecatedPropType(
      PropTypes.string,
      'Use the `source` prop instead.'
    ),

    /**
     * Loads static html or a uri (with optional headers) in the WebView.
     */
    source: PropTypes.oneOfType([
      PropTypes.shape({
        /*
         * The URI to load in the WebView. Can be a local or remote file.
         */
        uri: PropTypes.string,
        /*
         * The HTTP Method to use. Defaults to GET if not specified.
         * NOTE: On Android, only GET and POST are supported.
         */
        method: PropTypes.oneOf(['GET', 'POST']),
        /*
         * Additional HTTP headers to send with the request.
         * NOTE: On Android, this can only be used with GET requests.
         */
        headers: PropTypes.object,
        /*
         * The HTTP body to send with the request. This must be a valid
         * UTF-8 string, and will be sent exactly as specified, with no
         * additional encoding (e.g. URL-escaping or base64) applied.
         * NOTE: On Android, this can only be used with POST requests.
         */
        body: PropTypes.string,
      }),
      PropTypes.shape({
        /*
         * A static HTML page to display in the WebView.
         */
        html: PropTypes.string,
        /*
         * The base URL to be used for any relative links in the HTML.
         */
        baseUrl: PropTypes.string,
      }),
      /*
       * Used internally by packager.
       */
      PropTypes.number,
    ]),

    /**
     * Used on Android only, JS is enabled by default for WebView on iOS
     * @platform android
     */
    javaScriptEnabled: PropTypes.bool,

    /**
     * Used on Android Lollipop and above only, third party cookies are enabled
     * by default for WebView on Android Kitkat and below and on iOS
     * @platform android
     */
    thirdPartyCookiesEnabled: PropTypes.bool,

    /**
     * Used on Android only, controls whether DOM Storage is enabled or not
     * @platform android
     */
    domStorageEnabled: PropTypes.bool,

    /**
     * Sets the JS to be injected when the webpage loads.
     */
    injectedJavaScript: PropTypes.string,

    /**
     * Sets whether the webpage scales to fit the view and the user can change the scale.
     */
    scalesPageToFit: PropTypes.bool,

    /**
     * Sets the user-agent for this WebView. The user-agent can also be set in native using
     * WebViewConfig. This prop will overwrite that config.
     */
    userAgent: PropTypes.string,

    /**
     * Used to locate this view in end-to-end tests.
     */
    testID: PropTypes.string,

    /**
     * Determines whether HTML5 audio & videos require the user to tap before they can
     * start playing. The default value is `false`.
     */
    mediaPlaybackRequiresUserAction: PropTypes.bool,

    /**
     * Boolean that sets whether JavaScript running in the context of a file
     * scheme URL should be allowed to access content from any origin.
     * Including accessing content from other file scheme URLs
     * @platform android
     */
    allowUniversalAccessFromFileURLs: PropTypes.bool,

    /**
     * Function that accepts a string that will be passed to the WebView and
     * executed immediately as JavaScript.
     */
    injectJavaScript: PropTypes.func,

    /**
     * Specifies the mixed content mode. i.e WebView will allow a secure origin to load content from any other origin.
     *
     * Possible values for `mixedContentMode` are:
     *
     * - `'never'` (default) - WebView will not allow a secure origin to load content from an insecure origin.
     * - `'always'` - WebView will allow a secure origin to load content from any other origin, even if that origin is insecure.
     * - `'compatibility'` -  WebView will attempt to be compatible with the approach of a modern web browser with regard to mixed content.
     * @platform android
     */
    mixedContentMode: PropTypes.oneOf([
      'never',
      'always',
      'compatibility'
    ]),

    /**
     * Used on Android only, controls whether form autocomplete data should be saved
     * @platform android
     */
    saveFormDataDisabled: PropTypes.bool,

    /**
     * Override the native component used to render the WebView. Enables a custom native
     * WebView which uses the same JavaScript as the original WebView.
     */
    nativeConfig: PropTypes.shape({
      /*
       * The native component used to render the WebView.
       */
      component: PropTypes.any,
      /*
       * Set props directly on the native component WebView. Enables custom props which the
       * original WebView doesn't pass through.
       */
      props: PropTypes.object,
      /*
       * Set the ViewManager to use for communcation with the native side.
       * @platform ios
       */
      viewManager: PropTypes.object,
    }),
    /*
     * Used on Android only, controls whether the given list of URL prefixes should
     * make {@link com.facebook.react.views.webview.ReactWebViewClient} to launch a
     * default activity intent for those URL instead of loading it within the webview.
     * Use this to list URLs that WebView cannot handle, e.g. a PDF url.
     * @platform android
     */
    urlPrefixesForDefaultIntent: PropTypes.arrayOf(PropTypes.string),
      
      /*
      *Custom attribute used on Android only,
      * Provides support for the browse button to enable upload files
      */
      enabledUploadAndroid: PropTypes.bool,
  };

  static defaultProps = {
    javaScriptEnabled : true,
    thirdPartyCookiesEnabled: true,
    scalesPageToFit: true,
    saveFormDataDisabled: false
  };

  state = {
    viewState: WebViewState.IDLE,
    lastErrorEvent: null,
    startInLoadingState: true,
  };

  componentWillMount() {
    if (this.props.startInLoadingState) {
      this.setState({viewState: WebViewState.LOADING});
    }
  }

  render() {
    var otherView = null;

   if (this.state.viewState === WebViewState.LOADING) {
      otherView = (this.props.renderLoading || defaultRenderLoading)();
    } else if (this.state.viewState === WebViewState.ERROR) {
      var errorEvent = this.state.lastErrorEvent;
      otherView = this.props.renderError && this.props.renderError(
        errorEvent.domain,
        errorEvent.code,
        errorEvent.description);
    } else if (this.state.viewState !== WebViewState.IDLE) {
      console.error('RCTWebView invalid state encountered: ' + this.state.loading);
    }

    var webViewStyles = [styles.container, this.props.style];
    if (this.state.viewState === WebViewState.LOADING ||
      this.state.viewState === WebViewState.ERROR) {
      // if we're in either LOADING or ERROR states, don't show the webView
      webViewStyles.push(styles.hidden);
    }

    var source = this.props.source || {};
    if (this.props.html) {
      source.html = this.props.html;
    } else if (this.props.url) {
      source.uri = this.props.url;
    }

    if (source.method === 'POST' && source.headers) {
      console.warn('WebView: `source.headers` is not supported when using POST.');
    } else if (source.method === 'GET' && source.body) {
      console.warn('WebView: `source.body` is not supported when using GET.');
    }

    const nativeConfig = this.props.nativeConfig || {};

    let NativeWebView = nativeConfig.component || RCTWebView;

    var webView =
      <NativeWebView
        ref={ADVANCED_WEBVIEW_REF}
        key="webViewKey"
        style={webViewStyles}
        source={resolveAssetSource(source)}
        scalesPageToFit={this.props.scalesPageToFit}
        injectedJavaScript={this.props.injectedJavaScript}
        userAgent={this.props.userAgent}
        javaScriptEnabled={this.props.javaScriptEnabled}
        thirdPartyCookiesEnabled={this.props.thirdPartyCookiesEnabled}
        domStorageEnabled={this.props.domStorageEnabled}
        messagingEnabled={typeof this.props.onMessage === 'function'}
        onMessage={this.onMessage}
        contentInset={this.props.contentInset}
        automaticallyAdjustContentInsets={this.props.automaticallyAdjustContentInsets}
        onContentSizeChange={this.props.onContentSizeChange}
        onLoadingStart={this.onLoadingStart}
        onLoadingFinish={this.onLoadingFinish}
        onLoadingError={this.onLoadingError}
        testID={this.props.testID}
        mediaPlaybackRequiresUserAction={this.props.mediaPlaybackRequiresUserAction}
        allowUniversalAccessFromFileURLs={this.props.allowUniversalAccessFromFileURLs}
        mixedContentMode={this.props.mixedContentMode}
        saveFormDataDisabled={this.props.saveFormDataDisabled}
        urlPrefixesForDefaultIntent={this.props.urlPrefixesForDefaultIntent}
        enabledUploadAndroid={this.props.enabledUploadAndroid}
        {...nativeConfig.props}
      />;

    return (
      
        {webView}
        {otherView}
      </View>
    );
  }

  goForward = () => {
    UIManager.dispatchViewManagerCommand(
      this.getWebViewHandle(),
      UIManager.RCTWebView.Commands.goForward,
      null
    );
  };

  goBack = () => {
    UIManager.dispatchViewManagerCommand(
      this.getWebViewHandle(),
      UIManager.RCTWebView.Commands.goBack,
      null
    );
  };

  reload = () => {
    this.setState({
      viewState: WebViewState.LOADING
    });
    UIManager.dispatchViewManagerCommand(
      this.getWebViewHandle(),
      UIManager.RCTWebView.Commands.reload,
      null
    );
  };

  stopLoading = () => {
    UIManager.dispatchViewManagerCommand(
      this.getWebViewHandle(),
      UIManager.RCTWebView.Commands.stopLoading,
      null
    );
  };

  postMessage = (data) => {
    UIManager.dispatchViewManagerCommand(
      this.getWebViewHandle(),
      UIManager.RCTWebView.Commands.postMessage,
      [String(data)]
    );
  };

  /**
  * Injects a javascript string into the referenced WebView. Deliberately does not
  * return a response because using eval() to return a response breaks this method
  * on pages with a Content Security Policy that disallows eval(). If you need that
  * functionality, look into postMessage/onMessage.
  */
  injectJavaScript = (data) => {
    UIManager.dispatchViewManagerCommand(
      this.getWebViewHandle(),
      UIManager.RCTWebView.Commands.injectJavaScript,
      [data]
    );
  };

  /**
   * We return an event with a bunch of fields including:
   *  url, title, loading, canGoBack, canGoForward
   */
  updateNavigationState = (event) => {
    if (this.props.onNavigationStateChange) {
      this.props.onNavigationStateChange(event.nativeEvent);
    }
  };

  getWebViewHandle = () => {
    return ReactNative.findNodeHandle(this.refs[RCT_WEBVIEW_REF]);
  };

  onLoadingStart = (event) => {
    var onLoadStart = this.props.onLoadStart;
    onLoadStart && onLoadStart(event);
    this.updateNavigationState(event);
  };

  onLoadingError = (event) => {
    event.persist(); // persist this event because we need to store it
    var {onError, onLoadEnd} = this.props;
    onError && onError(event);
    onLoadEnd && onLoadEnd(event);
    console.warn('Encountered an error loading page', event.nativeEvent);

    this.setState({
      lastErrorEvent: event.nativeEvent,
      viewState: WebViewState.ERROR
    });
  };

  onLoadingFinish = (event) => {
    var {onLoad, onLoadEnd} = this.props;
    onLoad && onLoad(event);
    onLoadEnd && onLoadEnd(event);
    this.setState({
      viewState: WebViewState.IDLE,
    });
    this.updateNavigationState(event);
  };

  onMessage = (event: Event) => {
    var {onMessage} = this.props;
    onMessage && onMessage(event);
  }
}

var RCTWebView = requireNativeComponent('AdvancedWebView', AdvancedWebView, AdvancedWebView.extraNativeComponentConfig);

var styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  hidden: {
    height: 0,
    flex: 0, // disable 'flex:1' when hiding a View
  },
  loadingView: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  loadingProgressBar: {
    height: 20,
  },
});

module.exports = AdvancedWebView;

The below line in the above code is used to retrieve the native UI component to the react native side. The first parameter of requireNativeComponent is the name of the UI component and it should match the name specified inside the getName() method in the AdvancedWebViewManager class which we discussed above.

var RCTWebView = requireNativeComponent('AdvancedWebView', AdvancedWebView, AdvancedWebView.extraNativeComponentConfig);

We have now arrived to the final step which is to run the created AdvancedWebview UI component. You could run it by importing the AdvancedWebView component and adding it inside the render method of the class as you would do it with the WebView component provided by React Native. Additionally you will have to add the enabledUploadAndroid prop to enable the file upload functionality which we just created. An example code is shown below.

.....
import AdvancedWebView from 'path/AdvancedWebview.android';
....

render() {
    const { width, height } = Dimensions.get('window');  
    return (
        <AdvancedWebView
            source={{ uri: "https://lakshinkarunaratne.wordpress.com/"}}
            style={{ flex: 1,width,height}}
            enabledUploadAndroid = {true}
        />
        .......
    );
} 

Please note that this AdvancedWebview will work only on Android for now and you will have to use the regular WebView component for iOS.  In one of my later blog posts i will cover on how to extend its functionality to use it on iOS as well.

Advertisements

Wrapping an existing web application in React Native

Lets imagine a scenario that a development team has been given a task by a company which has an existing web application to package and deliver it as a mobile application. We will not debate the reasons for a such scenario and reasons for selecting React Native in this article. Instead will just focus on the steps for achieving it.

Following is a step by step guide to achieve the above scenario using React Native, a popular cross platform mobile app development framework which is used to develop natively rendering Android and iOS mobile applications.

Prerequisites

  • Knowledge on Javascript ES5 or above – Prior knowledge on Javascript ES5 or above is required to follow this tutorial.
  • Ensuring that your web application is mobile responsive – We will not be covering on how to make your web application mobile responsive in this article as its a separate topic but without having a mobile responsive web application it is not possible to wrap it as a mobile application since the content will not be visible accordingly in the mobile screen.
  • Node JS – You may download Node JS from  download Node JS.  We need it since we will be using the node package manager in this tutorial.
  • Setting up the developer environment for React Native CLI – As we are creating the wrapper using React Native, it is mandatory to configure the developer environment as mentioned in the “Building Projects with Native Code” tab in Configure React Native CLI . You may skip it if you have already configured.
  • Some knowledge on React Native – I will not go into each and every detail regarding react native specific things in this article but you can read them up here:  https://facebook.github.io/react-native/docs/tutorial.html.

Development

React Native Version:

Please note that the react native version that we will be using in this tutorial is 0.49 

First open your terminal if you’re on a mac or a Linux machine or the command prompt if you’re on a windows machine and  navigate to a destination of your liking.  Then run  react-native init WebWrapper command. “WebWrapper” is the name of the application given in this tutorial and you are free to change it according to your liking. This command will generate a react native project with the specified name.

Then we’ll create the folder structure for our application as mentioned below starting by creating a directory named app in the root directory of the generated application.

── app
   ├── components
   ├── config   
   └── screens

Lets start the development of the react native application by creating the screen which we are displaying our existing web application. Open the generated project using any preferred editor and inside screens folder add a new file named “Webview.js” and add the following code.

/app/screens/Webview.js

import React, { Component } from 'react';
import { StyleSheet, Text, View, WebView } from 'react-native';

export default class Webview extends Component {

    constructor(props) {
        super(props);
        this.src = 'http://www.espncricinfo.com/';
    }

    render() {
        return (
            <View style={{ flex: 1 }}>
                <WebView                   
                    source={{ uri: this.src }}
                    style={{ flex: 1 }}
                />
            </View>
        );
    }
}

In this code we have imported Webview from react native and added it inside the render method in order to load the existing web application. As shown in the above code we could specify the url of the web application by setting the value to the source attribute in the Webview element.

In order to run and test the above code we need to import this screen in our App.js which is the starting point of the react native application. App.js could be found in the root of the generated react native app directory and to import the above screen add the following code to it.

/App.js

import React, { Component } from 'react';
import { StyleSheet, Text, View} from 'react-native';
import Webview from './app/screens/Webview';

export default class App extends React.Component {

  constructor(props) {
    super(props);
  }

  render() {
    return (
      <Webview />
    );
  }
}

In this code we have imported the created Webview.js file from the screens directory and added it as an element in the render method.

Now you can run the application to test by executing the react-native run-ios or react-native run-android command line depending on the platform you want to test. Note: path of the current directory in the command line should be the root directory of the generated react native app when running the commands.
(Ex path :- C:\ReactNativeProjects\WebWrapper)

Display Loading While Page is Loading

When you run the react native application you might notice that it may take some time to fully load the web application. When talking about user experience, it is recommended to  display a loading screen while the site is being loaded. The easiest way to handle it, is to create a overlay with a loading message and a spinner.  Next lets look into creating the loading overlay for the react native app.

Create a folder named “overlays” inside the Components directory and add a file named “LoadingOverlay.js” and add the following code to it.

/app/component/overlays/LoadingOverlay.js

import React, { Component } from 'react';
import { StyleSheet, Text, View, ActivityIndicator } from 'react-native';
import { styles } from './styles';

export default class LoadingOverlay extends Component {

    constructor(props) {
        super(props);
        this.loadingText = 'Loading Please Wait ..';
    }

    render() {
        return (
            <View style={styles.overlay}>
                <Text style={styles.text}>{this.loadingText}</Text>
                <ActivityIndicator
                    animating={this.props.animating}
                    style={styles.activityIndicator}
                    size="large"
                    color='#fff'
                />
            </View>
        );
    }
}

Using a Text element we are displaying the loading message and the ActivityIndicator element is added to display a spinner. In the ActivityIndicator element, for the animating attribute we are taking the value from props which is passed by the parent component. You may notice that we have imported a styles file and added styles from it. Next lets look at creating the styles file for the Loading Overlay.

Create a file named styles.js inside the same folder which we created the LoadingOverlay.js and add the following code to it.

/app/component/overlays/styles.js

import { StyleSheet,Dimensions } from 'react-native';
const { width, height } = Dimensions.get('window');
export const styles = StyleSheet.create({
    overlay: {
        flex: 1,
        position: 'absolute',
        backgroundColor: '#000',
        width, height,
        opacity: 0.9,
        justifyContent: 'center',
        alignItems: 'center'
    },
    text: {
        flex: 0.15,
        fontSize: 30,
        width: 200,
        textAlign: 'center',
        color: '#fff'
    },
    activityIndicator: {
        justifyContent: 'center',
        alignItems: 'center'
    }
})

As shown in the above code we can get the width and height of the screen using
const { width, height } = Dimensions.get('window'); and use it in our styles accordingly. Now we are done creating the loading overlay. Before we import it in our Webview.js to test the application, lets create a file named index.js in the overlays folder so that we can import the Loading overlay just by specifying the path to the overlay folder. (If we have multiple overlays in that folder it would make it easier to import)

Create a file named index.js in the overlays folder and add the following code.

/app/component/overlays/index.js

import LoadingOverlay from './LoadingOverlay';
export {LoadingOverlay};

Now we can import the Loading overlay in our Webview.js file and test the application. Update the Webview.js file as displayed below.

import React, { Component } from 'react';
import { StyleSheet, Text, View, Platform } from 'react-native';
import {LoadingOverlay} from '../../components/overlays';

export default class Webview extends Component {

    constructor(props) {
        super(props);
        this.state = {
            isLoadingVIsible: true            
        }
        this.onLoadWebviewIos = this.onLoadWebviewIos.bind(this); 
        this.onLoadWebviewAndroid = this.onLoadWebviewAndroid.bind(this); 
        this.src = Config.SITE_URL;        
    }
  
    onLoadWebviewIos (navState) {      
        if(Platform.OS === 'ios'){
            this.setState({isLoadingVIsible: false})
        }        
      }
    
    onLoadWebviewAndroid (navState) {
        if(Platform.OS === 'android'){    
          if(navState.loading === false){
            this.setState({isLoadingVIsible: false});
          }           
        }
      }
    render() {        
        return (
            <View style={{ flex: 1 }}>
                <WebView                    
                    source={{ uri: this.src }}
                    style={{ flex: 1 }}
                    onNavigationStateChange={this.onLoadWebviewAndroid}
                    onLoad={this.onLoadWebviewIos}
                />
                {this.state.isLoadingVIsible ? (
                    <LoadingOverlay animating={true} />
                ) : null}                
            </View>
        );
    }
}

We can import the Loading overlay as
import {LoadingOverlay} from '../../components/Overlays'; because we created the index.js as mentioned above. After we imported it we need to display the Loading overlay only when the Webview is loading.  So to capture it we are using two events onNavigationStateChange and onLoad. You might wonder why we are using two events to handle the same thing. In reality when testing the application in devices, it was found that onNavigationStateChange does not work as expected in iOS and onLoad does not work as expected in Android . So as a work around i have used both events, executing only the required code depending on the platform.

Platform Specific Event Issue:

The react version which i used in this tutorial is 0.49, so maybe it would be fixed in one of the later versionsIf anyone has a better suggestion to handle it, please mention it below in the comments section.

To identify the platform we could import  Platform from react-native and use Platform.OS === 'ios' or Platform.OS === 'android'.  Now we can create a state variable named isLoadingVIsible which would initially be true and by using the above discussed events we could set it to false after the WebView has finished loading. In the render method we can create an inline if condition and display the Loading overlay when the isLoadingVIsible variable is true as shown in the above code.  When creating the Loading overlay i mentioned that the value for animating in the ActivityIndicator will be taken from the parent. So in Webview.js which is the parent, we will be passing that value ( animating={true} ). Now you can run the application and test the newly added Loading overlay.

Handle Internet Connectivity

In this application there may be a scenario which internet connectivity might suddenly be disconnected or not be available in the device at all. In such a scenario considering user experience its better to display a message to the user mentioning that the connectivity is not available as the user might not be aware of it. So for that also lets create an overlay.

In the overlays folder create a file named InternetOverlay.js and add the following code.

/app/component/overlays/InternetOverlay.js

import React, { Component } from 'react';
import { StyleSheet, Text, View, Dimensions, ActivityIndicator } from 'react-native';
import { styles } from './styles';

export default class InternetOverlay extends Component {

    constructor(props) {
        super(props);
      
      this.noInternettext = 'No Internet available';
    }

    render() {
        return (
            <View style={styles.overlay}>
                <Text style={styles.internettext}>{this.noInternettext}</Text>
            </View>
        );
    }
}

Now lets update our style.js with the internettext style as shown below.

/app/component/overlays/styles.js

import { StyleSheet,Dimensions } from 'react-native';
const { width, height } = Dimensions.get('window');
export const styles = StyleSheet.create({
    overlay: {
        flex: 1,
        position: 'absolute',
        backgroundColor: '#000',
        width, height,
        opacity: 0.9,
        justifyContent: 'center',
        alignItems: 'center'
    },
    text: {
        flex: 0.15,
        fontSize: 30,
        width: 200,
        textAlign: 'center',
        color: '#fff'
    },
    activityIndicator: {
        justifyContent: 'center',
        alignItems: 'center'
    },
    internettext: {
        flex: 0.3,
        fontSize: 30,
        width: 200,
        textAlign: 'center',
        color: '#fff'
    }  
})

To import the InternetOverlay.js in the Webview.js lets update our index.js as shown below.

/app/component/overlays/index.js

import LoadingOverlay from './LoadingOverlay';
import InternetOverlay from './InternetOverlay';

export {LoadingOverlay,InternetOverlay};

Now lets import it in our Webview.js. Update the Webview.js as shown below.

import React, { Component } from 'react';
import { StyleSheet, Text, View, WebView, NetInfo, Platform } from 'react-native';
import { styles } from './styles';
import {InternetOverlay, LoadingOverlay} from '../../components/overlays';

export default class Webview extends Component {

    constructor(props) {
        super(props);
        this.state = {
            isLoadingVIsible: true,
            internet: true            
        }
        this.handleFirstConnectivityChange = this.handleFirstConnectivityChange.bind(this);
        this.onLoadWebviewIos = this.onLoadWebviewIos.bind(this);
        this.onLoadWebviewAndroid = this.onLoadWebviewAndroid.bind(this);             
        this.WEBVIEW_REF = 'webview';
        this.src = 'http://www.espncricinfo.com/';

        NetInfo.isConnected.fetch().then(isConnected => {
            (isConnected ? this.setState({ internet: true }) : this.setState({ internet: false }))
        });

        NetInfo.isConnected.addEventListener(
            'connectionChange',
            this.handleFirstConnectivityChange
        );
    }

    handleFirstConnectivityChange (isConnected) {    
        if(isConnected){
          this.setState({internet : true, isLoadingVIsible: true})
          this.refs[this.WEBVIEW_REF].reload();
        }else{
          this.setState({internet : false});
        }   
      }
      onLoadWebviewIos (navState) {      
        if(Platform.OS === 'ios'){
            this.setState({isLoadingVIsible: false})
        }        
      }
    
      onLoadWebviewAndroid (navState) {
        if(Platform.OS === 'android'){    
            if(navState.loading === false){
            this.setState({isLoadingVIsible: false});
            }           
        }
      }     
      

    render() {        
        return (
            <View style={{ flex: 1 }}>
                <WebView
                    ref={this.WEBVIEW_REF}
                    source={{ uri: this.src }}
                    style={{ flex: 1 }}                    
                    onNavigationStateChange={this.onLoadWebviewAndroid}
                    onLoad={this.onLoadWebviewIos}
                />
                {this.state.isLoadingVIsible ? (
                    
                ) : null}                
                {this.state.internet == false ? (
                    <InternetOverlay />
                ) : null}
            </View>
        );
    }
}

As shown above we have imported NetInfo from react-native and used it to get the required connectivity details. We will keep a state variable named internet so that we can display the InternetOverlay when required.  In the constructor we are getting the current internet connectivity status using the below code and updating the internet state variable depending on the current status.
NetInfo.isConnected.fetch().then(isConnected => { (isConnected ? this.setState({ internet: true }) : this.setState({ internet: false })) });

Still, by using the above code its only possible to get the connectivity status at the start of the application. We also need to capture the connectivity change and display the InternetOverlay if internet suddenly disconnects. To achieve it we will add an event using the below code to keep track of the connectivity.
NetInfo.isConnected.addEventListener( 'connectionChange', this.handleFirstConnectivityChange );
This code will call the handleFirstConnectivityChange method when a connection change occurs, passing the connection status as a parameter. If the connection disconnects it will update the state variable “internet” to false and will display the internet overlay.

When the connection connects again it will update the “internet” state variable to true which will hide the internet overlay. But that’s not enough as there could be a scenario which the webpage would be half loaded or not loaded at all, at the time the connection disconnects . So it’s better to reload the WebView when the connection becomes active again. To perform that we need to invoke the reload method in the WebView component. So we use ref as shown in the above code to get a reference of the component .  Using this.refs[this.WEBVIEW_REF].reload(); we could call the reload method in the WebView component and reload the webpage when connection becomes active.

Now you could finally run the application using react-native run-ios or
react-native run-android and test the application.

Magento 1.9 – Remove delete product access for a specific user role

There may be a situation where you do not want a specific set of users who users the admin panel to have access to delete products which are already added to the site. The following tutorial is a guide to remove delete product access for a specific user role.

Create a custom module. Make the necessary name changes and add the following code to the etc/config.xml. In the following code rewriting adminthtml to remove the delete and mass delete and events are to lock a particular attribute if you don’t want to give the user role to edit that attribute.

<blocks>
   <custom_catalog>
      <class>Custom_Catalog_Block</class>
   </custom_catalog>
   <adminhtml>
      <rewrite>
         <catalog_product_edit>Custom_Catalog_Block_Adminhtml_Product_Edit</catalog_product_edit>
         <catalog_product_grid>Custom_Catalog_Block_Adminhtml_Product_Grid</catalog_product_grid>
      </rewrite>
   </adminhtml>
</blocks>
<events>
   <catalog_product_edit_action>
      <observers>
         <custom_catalog>
            <type>singleton</type>
            <class>custom_catalog/observer</class>
            <method>lockAttributes</method>
         </custom_catalog>
      </observers>
   </catalog_product_edit_action>
   <catalog_product_new_action>
      <observers>
         <custom_catalog>
            <type>singleton</type>
            <class>custom_catalog/observer</class>
            <method>lockAttributes</method>
         </custom_catalog>
      </observers>
   </catalog_product_new_action>
</events>

Block/Adminhtml/Product/Edit.php
(This is to remove the delete button in the product edit page)

<?php 
class Custom_Catalog_Block_Adminhtml_Product_Edit extends Mage_Adminhtml_Block_Catalog_Product_Edit
{
 public function getDeleteButtonHtml()
    {
        $currentUser = Mage::getSingleton('admin/session')->getUser();
        $currentRole = $currentUser->getRole();
        $roleId = $currentRole->getId();
        //hardcoded the user role id for demo purposes
        if($roleId == 1){
            return $this->getChildHtml('delete_button');
        }else{
             return '';
        }

    }
}

Block/Adminhtml/Product/Grid.php
(This is to remove the mass delete from actions in all products page)

<?php 
class Custom_Catalog_Block_Adminhtml_Product_Grid extends Mage_Adminhtml_Block_Catalog_Product_Grid
{
    protected function _prepareMassaction()
    {
        parent::_prepareMassaction();
         $currentUser = Mage::getSingleton('admin/session')->getUser();
        $currentRole = $currentUser->getRole();
        $roleId = $currentRole->getId();
        //hardcoded the user role id for demo purposes
        if($roleId != 1){
            //like this you can remove other actions as well. eg:- status
            $this->getMassactionBlock()->removeItem('delete');               
        }
        return $this;
    }
}

Model/Observer.php
(To lock attributes in product edit page if required)

<?php
class Custom_Catalog_Model_Observer {

    public function lockAttributes($observer) {
        $event = $observer->getEvent();
        $product = $event->getProduct();
        $currentUser = Mage::getSingleton('admin/session')->getUser();
        $currentRole = $currentUser->getRole();
        $roleId = $currentRole->getId();
        //hardcoded the user role id for demo purposes
        if($roleId != 1){
            //like this you can lock any attribute you like in product edit
            $product->lockAttribute('price');
            $product->lockAttribute('status');
        }
    }
}

The user role id is hardcoded for demo purposes in the above example and you may use it accordingly to get the desired result. Also as shown above you have the ability to lock other attributes such as price for a specific user role.

 

Magento 1.9 – Display multiple payment methods using one payment module

Create a custom payment gateway plugin for magento 1.9 and change the following to display multiple payment methods using one payment module. (I have created a custom payment gateway plugin with the namespace Custom and the module name Pay.)

Custom/Pay/Model/Custom_pay_Model_Paymodel.php
Define variable to add a custom form for payment method in checkout
(protected $_formBlockType = ‘pay/form_pay’;)

<?php 
class Custom_Pay_Model_paymodel extends Mage_Payment_Model_Method_Abstract {
    protected $_code = 'pay';

    protected $_isInitializeNeeded      = true;
    protected $_canUseInternal          = true;
    protected $_canUseForMultishipping  = false;

    // define variable to add a custom form for payment method in checkout
    protected $_formBlockType = 'pay/form_pay';

    //this is the function to get the data in the custom form
    public function assignData($data)
    {
        Mage::log($data->getPayInstallment());
    }
    public function getOrderPlaceRedirectUrl() {
        return Mage::getUrl('pay/payment/redirect', array('_secure' => true));
    }       
}

Custom/pay/Block/Form/Pay.php

Set the template path of the from phtml ($this->setTemplate(‘pay/form/pay.phtml’);

<!--?php 
class Custom_Pay_Block_Form_Pay extends Mage_Payment_Block_Form
{
    protected function _construct()
    {
        parent::_construct();
        $this->setTemplate('pay/form/pay.phtml');
    }
}

design/frontend/base/template/pay/form/pay.phtml

<ul class="form-list" id="payment_form_<!--?php echo $this->getMethodCode() ?-->" style="display:none;">
<li>
    &nbsp
</li>    
<li>     
    <input type="radio" style="margin-left: 20px;" id="paysix" name="payment[pay_installment]" value="6" checked>
    <label for="paysix" style="float:left">6 Months</label>    

    <input type="radio" style="margin-left: 10px;" id="paytwelve" name="payment[pay_installment]" value="12">
    <label for="paytwelve">12 Months</label>    
</li>
</ul>

Using the above code a custom form could be shown for the payment method with 2 radio buttons. Then in the assign data function the value could be added to the Mage::Registry and take it from the block which the request form is created and initialize the form according to the selected values.