com.google.appinventor.components.runtime.Survey.java Source code

Java tutorial

Introduction

Here is the source code for com.google.appinventor.components.runtime.Survey.java

Source

// -*- mode: java; c-basic-offset: 2; -*-
// Copyright 2009-2011 Google, All Rights reserved
// Copyright 2011-2012 MIT, All rights reserved
// Released under the MIT License https://raw.github.com/mit-cml/app-inventor/master/mitlicense.txt
package com.google.appinventor.components.runtime;

import java.io.*;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashMap;
import java.util.Locale;
import android.os.Handler;
import android.os.Environment;

import com.google.appinventor.components.annotations.*;
import com.google.appinventor.components.common.ComponentConstants;

import org.json.JSONObject;

import com.google.appinventor.components.common.ComponentCategory;
import com.google.appinventor.components.common.PropertyTypeConstants;

import com.google.appinventor.components.common.YaVersion;

import com.google.appinventor.components.runtime.util.AsynchUtil;
import com.google.appinventor.components.runtime.util.HttpsUploadService;
import com.google.appinventor.components.runtime.util.SdkLevel;
import com.google.appinventor.components.runtime.util.SensorDbUtil;
import com.google.appinventor.components.runtime.util.YailList;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;

import edu.mit.media.funf.config.RuntimeTypeAdapterFactory;
import edu.mit.media.funf.storage.DatabaseService;
import edu.mit.media.funf.storage.NameValueDatabaseService;
import edu.mit.media.funf.storage.UploadService;
import edu.mit.media.funf.time.DecimalTimeUnit;
import java.math.BigDecimal;

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.webkit.WebView;
import android.util.Log;

/**
 * Component for displaying surveys
 * This component makes use of Android WebView to display survey question. AI Developer could
 * customize survey question and survey types. After the questions are answered, 
 * they will be saved into local database and upload to remote server (Trust Framework).
 * For simplicity, there's only one question in one survey component
 *
 * @author fuming@mit.mit (Fuming Shih)
 */

@DesignerComponent(version = YaVersion.SURVEY_COMPONENT_VERSION, category = ComponentCategory.SOCIAL, description = "Component for displaying surveys. This component makes use "
        + "of Android WebView to display survey question. AI Developer "
        + "could customize survey question and survey types. After the questions "
        + "are answered, they will be saved into local database and upload to remote server."
        + "We suggest to put survey component in a standalone screen. The survey can be "
        + "triggered by two ways, 1) through the user interaction on the main screens, "
        + "2) through the user triggers the screen that contains this survey by tapping the " + "notification.")
@SimpleObject
@UsesLibraries(libraries = "funf.jar")
@UsesAssets(fileNames = "jquery.mobile.min.css," + "jquery.min.js," + "jquery.mobile.min.js," + "checkbox.html,"
        + "chooselist.html," + "multipleChoice.html," + "scale.html," + "textarea.html," + "textbox.html,"
        + "yesno.html")
@UsesPermissions(permissionNames = "android.permission.WRITE_EXTERNAL_STORAGE, " + "android.permission.INTERNET")
public class Survey extends AndroidViewComponent {

    public static final String SURVEY_HEADER = "edu.mit.csail.dig.survey";
    // The survey data will live in the same database as all the other sensor components
    private static final String SURVEY_DBNAME = "__SENSOR_DB__";//use the same db as other sensors

    private static Form mainUI;
    private SharedPreferences prefs;
    private String exportRoot; // The exportRoot is the same with NameValueDatabaseService
    private final WebView webview;
    private final static String TEXTBOX = "textbox";
    private final static String TEXTAREA = "textarea";
    private final static String MULTIPLECHOICE = "multipleChoice"; //it's actually radio button
    private final static String CHOOSELIST = "chooselist";
    private final static String CHECKBOX = "checkbox";
    private final static String SCALE = "scale";
    private final static String YESNO = "yesno";

    private static final String TAG = "Survey";

    private static final boolean DEFAULT_DATA_UPLOAD_ON_WIFI_ONLY = true;

    private String style = "";
    private String htmlContent = "";
    private String question = "";
    private String surveyGroup = "";
    private ArrayList<String> options = new ArrayList<String>();
    private String initValues = "";
    private boolean isRecovered = false;
    private String styleFromIntent = "";

    // for data uploading config.
    private Calendar calendar = Calendar.getInstance(Locale.getDefault());
    private BigDecimal localOffsetSeconds = BigDecimal
            .valueOf(calendar.get(Calendar.ZONE_OFFSET) + calendar.get(Calendar.DST_OFFSET), DecimalTimeUnit.MILLI);

    //store mapping to survey template (survey templates are store current in the asset folder)
    private static final HashMap<String, String> surveyTemplate = new HashMap<String, String>();
    static {
        surveyTemplate.put(TEXTBOX, getTemplatePath(TEXTBOX));
        surveyTemplate.put(YESNO, getTemplatePath(YESNO));
        surveyTemplate.put(TEXTAREA, getTemplatePath(TEXTAREA));
        surveyTemplate.put(MULTIPLECHOICE, getTemplatePath(MULTIPLECHOICE));
        surveyTemplate.put(CHOOSELIST, getTemplatePath(CHOOSELIST));
        surveyTemplate.put(CHECKBOX, getTemplatePath(CHECKBOX));
        surveyTemplate.put(SCALE, getTemplatePath(SCALE));

    }

    private String exportFormat = NameValueDatabaseService.EXPORT_CSV;

    /*
     * We store the template in the App Inventor's assets and point WebViewer
     * to it (file:///android_asset/"). The templates used by the Survey components
     * are stored in buildserver/src/com/google/appinventor/buildserver/resources
     */
    private static String getTemplatePath(String name) {
        return name + ".html";
        //return "file:///android_asset/" + name + ".html";
    }

    /**
     * Creates a new Survey component.
     * 
     * @param container
     *            container the component will be placed in
     * @throws IOException 
     */
    public Survey(ComponentContainer container) throws IOException {
        super(container);
        mainUI = container.$form();
        exportRoot = new java.io.File(Environment.getExternalStorageDirectory(), mainUI.getPackageName())
                + java.io.File.separator + "export";

        JsonParser parse = new JsonParser();
        webview = new WebView(container.$context());
        webview.getSettings().setJavaScriptEnabled(true);
        webview.setFocusable(true);
        webview.setVerticalScrollBarEnabled(true);

        container.$add(this);

        webview.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                case MotionEvent.ACTION_UP:
                    if (!v.hasFocus()) {
                        v.requestFocus();
                    }
                    break;
                }
                return false;
            }
        });

        // set the initial default properties. Height and Width
        // will be fill-parent, which will be the default for the web viewer.

        Width(LENGTH_FILL_PARENT);
        Height(LENGTH_FILL_PARENT);

        //set default survey style
        style = TEXTBOX; //default style

        // see if the Survey is created by someone tapping on a notification! 
        // create the Survey and load it in the webviewer
        /* e.g. 
         * value = {
         * "style": "multipleChoice", 
         * "question": "What is your favorite food"
         * "options": ["apple", "banana", "strawberry", "orange"],
         * "surveyGroup": "MIT-food-survey"
         * }
         * 
         */
        initValues = container.$form().getSurveyStartValues();
        Log.i(TAG, "startVal Suvey:" + initValues.toString());
        if (initValues != "") {

            JsonObject values = (JsonObject) parse.parse(initValues);
            this.style = values.get("style").getAsString();
            this.question = values.get("question").getAsString();
            this.surveyGroup = values.get("surveyGroup").getAsString();
            ArrayList<String> arrOptions = new ArrayList<String>();
            JsonArray _options = values.get("options").getAsJsonArray();
            for (int i = 0; i < _options.size(); i++) {
                arrOptions.add(_options.get(i).getAsString());
            }

            this.options = arrOptions;
            this.styleFromIntent = values.get("style").getAsString();
            Log.i(TAG, "Survey component got created");
        }

    }

    @Override
    public View getView() {
        // TODO Auto-generated method stub
        return webview;
    }

    // Components don't normally override Width and Height, but we do it here so
    // that
    // the automatic width and height will be fill parent.
    @Override
    @SimpleProperty()
    public void Width(int width) {
        if (width == LENGTH_PREFERRED) {
            width = LENGTH_FILL_PARENT;
        }
        super.Width(width);
    }

    @Override
    @SimpleProperty()
    public void Height(int height) {
        if (height == LENGTH_PREFERRED) {
            height = LENGTH_FILL_PARENT;
        }
        super.Height(height);
    }

    /*
     * This will load up the survey in the WebView. Need to set style, set question, and set options 
     * use webView.loadData() to load from an HTML string
     * 
     */
    @SimpleFunction(description = "Set survey style, set question before" + "call LoadSurvey")
    public void LoadSurvey() throws IOException {
        Log.i(TAG, "Before load data");
        this.htmlContent = genSurvey();
        Log.i(TAG, "HTML: " + this.htmlContent);
        //before loading we bind webview with SaveSurvey inner class to interface with js

        this.webview.addJavascriptInterface(
                new SaveSurvey(this.container.$form(), this.surveyGroup, this.style, this.question, this.options),
                "saveSurvey");

        //see http://pivotallabs.com/users/tyler/blog/articles/1853-android-webview-loaddata-vs-loaddatawithbaseurl-
        //http://myexperiencewithandroid.blogspot.com/2011/09/android-loaddatawithbaseurl.html
        this.webview.loadDataWithBaseURL("file:///android_asset/component/", this.htmlContent, "text/html", "UTF-8",
                null);

        Log.i(TAG, "After load data");

    }

    @SimpleProperty()
    public void SetSurveyGroup(String surveyGroup) {
        this.surveyGroup = surveyGroup;

    }

    /**
     * Sets the style of the survey
     * @param question
     */

    @DesignerProperty(editorType = PropertyTypeConstants.PROPERTY_TYPE_SURVEY_STYLE, defaultValue = "1")
    @SimpleProperty(description = "Set the style of the survey with integer. 1 = textbox, 2 = textarea, "
            + "3 = multiplechoice, 4 = chooselist, 5 = checkbox, 6 = scale, 7 = yesno")
    public void SetStyle(int style) {
        Log.i(TAG, "the style is: " + style);
        switch (style) {
        case ComponentConstants.SURVEY_STYLE_TEXTBOX:
            this.style = TEXTBOX;
            break;
        case ComponentConstants.SURVEY_STYLE_TEXTAREA:
            this.style = TEXTAREA;
            break;
        case ComponentConstants.SURVEY_STYLE_MULTIPLECHOICE:
            this.style = MULTIPLECHOICE;
            break;
        case ComponentConstants.SURVEY_STYLE_CHOOSELIST:
            this.style = CHOOSELIST;
            break;
        case ComponentConstants.SURVEY_STYLE_CHECKBOX:
            this.style = CHECKBOX;
            break;
        case ComponentConstants.SURVEY_STYLE_SCALE:
            this.style = SCALE;
            break;
        case ComponentConstants.SURVEY_STYLE_YESNO:
            this.style = YESNO;
            break;
        default:
            this.style = TEXTBOX;

        }

        // currently SetStyle() is called after the constructor to assign 
        // this.style from the value that is set in app inventor's designer 
        // and it will overwrite the intent's value. 
        // The code below is a quick workaround to force-write again, what's passed
        // from the intent
        if (!this.initValues.isEmpty() && !this.isRecovered) {
            this.style = this.styleFromIntent;
        }

    }

    @SimpleProperty()
    public void SetQuestion(String question) {
        this.question = question;

    }

    /*
     * This is for survey that is of type: MultipleChoice, ChooseList, CheckBox and Scale
     * Note that for Scale, only three options will 
     */
    @SimpleFunction(description = "For survey style MultipleChoice, ChooseList, CheckBox and ScalePass"
            + "use this to pass in options for survey answers. Note for Scale, "
            + "only three options should be passed in and in the order of \"min\", \"max\", "
            + "\"default\" value of the scale")
    public void SetOptions(YailList options) {
        String[] objects = options.toStringArray();

        for (int i = 0; i < objects.length; i++) {
            this.options.add(objects[i]);
        }

    }

    private String genOptions() {
        /*
         * For multiplechoice
         * <input id="radio1" name="" value="" type="radio" />
           *     <label for="radio1">
           *      Streeful
           *     </label>
           * For checkBox:
           * <input id="checkbox1" name="" type="checkbox" />
           *      <label for="checkbox1">
           *       Apple
           *      </label>
           * 
           * For chooselist:
           * 
           * <option value="option1">
           *   Option 1
           * </option>
         */
        StringBuilder optHtml = new StringBuilder();
        String optMFormat = "<input id=\"radio%d\" name=\"ans\" value=\"%s\" type=\"radio\" />\n";
        String optMLableFormat = "<label for=\"radio%d\"> %s </label> \n";

        if (this.options.isEmpty()) {
            return optHtml.toString();

        }
        if (this.style.equals(this.MULTIPLECHOICE) || this.style.equals(this.YESNO)) {
            int i = 1;
            for (String option : this.options) {
                optHtml.append(String.format(optMFormat, i, option));
                optHtml.append(String.format(optMLableFormat, i, option));
                i++;
            }

        }

        String optCFormat = "<input id=\"checkbox%d\" name=\"ans\" value=\"%s\" type=\"checkbox\" />\n";
        String optCLableFormat = "<label for=\"checkbox%d\"> %s </label> \n";
        Log.i(TAG, "before entering checkbox");
        if (this.style.equals(this.CHECKBOX)) {
            int j = 1;
            for (String option : this.options) {
                optHtml.append(String.format(optCFormat, j, option));
                optHtml.append(String.format(optCLableFormat, j, option));
                j++;
            }

        }
        String optLFormt = "<option value=\"option%d\"> %s </option>";
        if (this.style.equals(this.CHOOSELIST)) {
            int k = 1;
            for (String option : this.options) {
                optHtml.append(String.format(optLFormt, k, option));

                k++;
            }

        }
        Log.i(TAG, "gen options for:(" + this.style + ")" + optHtml.toString());
        return optHtml.toString();

    }

    // in the html files for survey templates
    private static void close(String result) {
        Log.i(TAG, "before closing the Activity");

        //mainUI.finishActivityWithTextResult(result);
        mainUI.finish();
    }

    /*
     * use webView.loadData() to load from an HTML string
     * 1. fetch the template according to survey style
     * 2. replace survey's question and survey's options in the template
     */
    private String genSurvey() throws IOException {
        Log.i(TAG, "the style is: " + style);
        //      String templatePath  = "component" + File.separator + getTemplatePath(this.style);
        String templatePath = "component" + java.io.File.separator + surveyTemplate.get(this.style);

        BufferedInputStream in = new BufferedInputStream(container.$context().getAssets().open(templatePath));

        //BufferedInputStream in = new BufferedInputStream(MediaUtil.openMedia(container.$form(), templatePath));

        //read it with BufferedReader
        BufferedReader br = new BufferedReader(new InputStreamReader(in));

        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = br.readLine()) != null) {
            sb.append(line);
        }

        Log.i(TAG, "Before replace:" + sb.toString());

        //A. generate question
        //find question block start with <h1 id="_txt_qt">
        String questionBlk = "<h1 id=\"_txt_qt\">";
        int insertPos = sb.indexOf(questionBlk) + questionBlk.length();

        int insertQuestionPos = sb.indexOf("Question", insertPos);

        sb.replace(insertQuestionPos, insertQuestionPos + "Question".length(), this.question);

        Log.i(TAG, "after replace question:" + sb.toString());
        // B. generate options
        // 1. find options block (depends on style)
        // only "multipleChoice", "checkBox", and "chooseList" need to replace with options; 
        // 2. For "scale" style, the first three options will be specifying the 
        // min, max and default value of the scale

        if (this.style.equals(this.MULTIPLECHOICE) || this.style.equals(this.CHECKBOX)) {

            int startPos = sb.indexOf("</legend>") + "</legend>".length();
            int endPos = sb.indexOf("</fieldset>");
            Log.i(TAG, "before replace options:");
            sb.replace(startPos, endPos, genOptions()); //replace with the filled-in options

        }
        if (this.style.equals(this.CHOOSELIST)) {
            int startPos = sb.indexOf("<select name=\"\">") + "<select name=\"\">".length();
            int endPos = sb.indexOf("</select>");

            sb.replace(startPos, endPos, genOptions());
        }

        if (this.style.equals(this.SCALE)) {

            if (!this.options.isEmpty() && this.options.size() == 3) {
                //replace min
                int sliderPos = sb.indexOf("input name=\"slider\"");
                int startPosOfMin = sb.indexOf("min=\"1\"", sliderPos + "input name=\"slider\"".length());
                // example: min="1" 
                sb.replace(startPosOfMin, startPosOfMin + 7, "min=\"" + this.options.get(0) + "\"");
                // replace max
                // example: replace max="10" to max="100"
                int startPosOfMax = sb.indexOf("max=\"10\"", sliderPos + "input name=\"slider\"".length());

                sb.replace(startPosOfMax, startPosOfMax + 8, "max=\"" + this.options.get(1) + "\"");

                // replace default initial scale
                // example: value="5"
                int startPosOfDefault = sb.indexOf("value=\"5\"", sliderPos + "input name=\"slider\"".length());

                sb.replace(startPosOfDefault, startPosOfDefault + 9, "value=\"" + this.options.get(2) + "\"");

            } else {
                ; //do nothing, use the template
            }

        }

        return sb.toString();

    }

    /*
     * 1. This inner class is to create an interface between javascript in WebView and Android
     * We assume that for most of the cases, the survey will be used in experience sampling. 
     * So survey results are stored together in the same db as the sensor probe. The results will 
     * be archived and uploaded to remote DB according how the user configure the Probe. 
     * 
     * 2. We also assume that other probes on the app has already obtained accessToken 
     *    and saved locally (in prefs).
     */
    public class SaveSurvey {
        private String databasename = SURVEY_DBNAME; //use the same database as funf data

        private String surveyGroup;
        Context mContext;
        private String style;
        private String question;
        private ArrayList<String> options;

        public SaveSurvey(Context context, String surveyGroup, String style, String question, ArrayList options) {
            mContext = context;
            this.surveyGroup = surveyGroup;
            this.style = style;
            this.question = question;
            this.options = options;

        }

        private void closeApp(String result) {
            Log.i(TAG, "Closing the app");
            Survey.close(result);

        }

        private void archiveData() {

            Intent i = new Intent(mContext, NameValueDatabaseService.class);
            Log.i(TAG, "archiving data....");
            i.setAction(DatabaseService.ACTION_ARCHIVE);
            i.putExtra(DatabaseService.DATABASE_NAME_KEY, databasename);
            mContext.startService(i);

        }

        public void saveResponse(String answer) {
            Log.i(TAG, "saveResponse is called");
            final long timestamp = System.currentTimeMillis() / 1000;
            final String style = this.style;
            final String surveyGroup = this.surveyGroup;
            final String question = this.question;
            final ArrayList<String> options = this.options;

            Log.i(TAG, "survey answer:" + answer.toString());

            // prepare the json object for survey value 

            JsonElement surveyData = new JsonObject();

            ((JsonObject) surveyData).addProperty("style", style);
            ((JsonObject) surveyData).addProperty("surveyGroup", surveyGroup);
            ((JsonObject) surveyData).addProperty("question", question);

            JsonElement optionsData = new JsonArray();

            for (String option : options) {
                JsonPrimitive p = new JsonPrimitive(option);
                ((JsonArray) optionsData).add(p);
            }
            ((JsonObject) surveyData).add("options", optionsData);

            // if the answer is from checkbox style, then we have multiple selections of answers
            if (style.equals(Survey.CHECKBOX)) {
                JsonElement surveyAnswers = new JsonArray();

                for (String ans : answer.split(",")) {
                    JsonPrimitive p = new JsonPrimitive(ans);
                    ((JsonArray) surveyAnswers).add(p);
                }

                ((JsonObject) surveyData).add("answer", surveyAnswers);

            } else {
                ((JsonObject) surveyData).addProperty("answer", answer);

            }
            ((JsonObject) surveyData).addProperty("probe", SURVEY_HEADER);
            ((JsonObject) surveyData).add("timezoneOffset", new JsonPrimitive(localOffsetSeconds));
            ((JsonObject) surveyData).addProperty("timestamp", System.currentTimeMillis() / 1000);

            // write to DB

            Bundle b = new Bundle();
            b.putString(NameValueDatabaseService.DATABASE_NAME_KEY, databasename);
            b.putLong(NameValueDatabaseService.TIMESTAMP_KEY, timestamp);
            b.putString(NameValueDatabaseService.NAME_KEY, SURVEY_HEADER);

            b.putString(NameValueDatabaseService.VALUE_KEY, surveyData.toString());
            Intent i = new Intent(mContext, NameValueDatabaseService.class);
            i.setAction(DatabaseService.ACTION_RECORD);
            i.putExtras(b);
            mContext.startService(i);

            // we close the open activity with returned result
            closeApp(answer);
        }

    }

    /**
     *
     * @return
     */
    @SimpleProperty(category = PropertyCategory.BEHAVIOR)
    public String DBName() {
        return SURVEY_DBNAME;

    }

    /*
     * Exporting Survey DB to External Storage
     */
    @SimpleFunction(description = "Export Survey results database as CSV files or JSON files."
            + "Input \"csv\" or \"json\" for exporting format.")
    public void Export(String format) {
        if (format == NameValueDatabaseService.EXPORT_JSON)
            this.exportFormat = NameValueDatabaseService.EXPORT_JSON;
        else
            this.exportFormat = NameValueDatabaseService.EXPORT_CSV;
        //if the user input the wrong format, we will just use csv by default
        Log.i(TAG, "exporting data...at: " + System.currentTimeMillis());

        Bundle b = new Bundle();
        b.putString(NameValueDatabaseService.DATABASE_NAME_KEY, SURVEY_DBNAME);
        b.putString(NameValueDatabaseService.EXPORT_KEY, format);
        Intent i = new Intent(mainUI, NameValueDatabaseService.class);
        i.setAction(DatabaseService.ACTION_EXPORT);
        i.putExtras(b);
        mainUI.startService(i);

    }

    @SimpleProperty(category = PropertyCategory.BEHAVIOR, description = "Get the export path for survey results")
    public String ExportFolderPath() {
        // the real export path is exportPath + "/" + exportformat
        return this.exportRoot + java.io.File.separator + this.exportFormat;

    }

    @SimpleFunction(description = "This will clean up the survey database on the smartphone")
    public void DeleteSurveyDB() {
        Intent i = new Intent(mainUI, NameValueDatabaseService.class);
        Log.i(TAG, "archiving data...at: " + System.currentTimeMillis());
        i.setAction(DatabaseService.ACTION_ARCHIVE);
        i.putExtra(DatabaseService.DATABASE_NAME_KEY, SURVEY_DBNAME);
        mainUI.startService(i);
    }

}