package ca.yorku.cse.mack.tiltmaze;

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.res.Configuration;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.media.MediaScannerConnection;
import android.os.Bundle;
import android.os.CountDownTimer;
import android.os.Environment;
import android.os.Vibrator;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Surface;
import android.view.WindowManager;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Locale;

/**
 * <h1>TiltMaze</h1>
 *
 * <h2>Summary</h2>
 *
 * <ul>
 * <li>Android experiment software investigating the use of device tilt to control the movement of a virtual
 * object on a display
 * </ul>
 * <p>
 *
 * <h3>Related References</h3>
 *
 * The following publication presents research where a variation of this software was used.
 * <p>
 *
 * <ul>
 * <li>
 * <a href="http://www.yorku.ca/mack/ieeegem2014a.html">Tilt-controlled mobile games:
 * Velocity-control vs. position-control</a>, by Constantin and MacKenzie (<i>IEEE GEM 2014</i>).
 * <p>
 * </ul>
 * <p>
 *
 * <h3>Setup Parameters</h3>
 *
 * The application launches with a setup dialog: (click to enlarge)
 * <p>
 *
 * <center>
 * <a href="TiltMaze-1.jpg"><img src="TiltMaze-1.jpg" width=200 alt="image"></a>
 * </center><p>
 *
 * In all, there are 12 setup parameters, organized in eight spinners and four checkboxes. The setup parameters are
 * explained as follows. <p>
 *
 * <blockquote>
 * <table border="1" cellspacing="0" cellpadding="6" valign="top">
 *
 * <tr bgcolor="#cccccc">
 * <th align="center">Parameter
 * <th align="center">Description
 *
 * <tr valign="top"> <td>Participant Code <td>A code to identify the user being tested. In experimental research, a user
 * is typically called a "participant". <p>
 *
 * <tr valign="top"> <td>Session Code <td>A code to identify the session. This code could be used in an a longitudinal
 * experiment, where participants are tested over multiple sessions. Each session might involve an hour of testing, with
 * one session per day over multiple days. <p>
 *
 * <tr valign="top">
 * <td>Block Code
 * <td>Note: There is an inactive setup parameter for "Block code". The block code is generated automatically by the
 * software. <p>
 *
 * <tr valign="top"> <td>Group Code <td>A code to identify the group. If the experiment uses a within-subjects design,
 * participants are usually assigned to groups. This code identifies the group. This procedure is known as
 * <i>counterbalancing</i>, which is used to offset order effects. (See section 5.11 in the course's Suggested Readings
 * for complete details.) <p>
 *
 * <tr valign="top"> <td>Condition Code <td>A code to identify an external condition. This setup item is an editable
 * text field. Tap to popup an input method editor to change the value. <p>
 *
 * The condition code is useful if the experiment included an independent variable pertaining to an environmental
 * condition or a context of use. As an example, an experiment might include "mobility" as an independent variable with
 * levels "sitting" and "walking". The code "Sitting" might be used for the sitting condition and the code "Walking" for
 * the walking condition. These codes appear in the output data to facilitate analyses. BTW, an example of an experiment
 * using this independent variable is described by Fitton et al. in <a href="http://www.yorku.ca/mack/bhci2013
 * .html">Exploring Tilt-Based Text Input For Mobile Devices With Teenagers</a>. <p>
 *
 * <tr valign="top">
 * <td>Number of Trials
 * <td>The number of trials for the current launch of the application
 * <p>
 *
 * <tr valign="top">
 * <td>Order of Control
 * <td>The options are<p>
 *
 * <ul>
 * <li>Velocity &ndash; tilting the device controls the velocity of the virtual ball
 * <li>Position &ndash; tilting the device controls the position of the virtual ball
 * </ul><p>
 *
 * <tr valign="top">
 * <td>Gain
 * <td>A value from the set { Very low, Low, Medium, High, Very high } roughly corresponding to gain
 * sensitivities ranging for very low to very high.<p>
 *
 * <tr valign="top">
 * <td>Show Nominal Path
 * <td>A checkbox item. If selected, a light grey line appears showing a nominal path through the maze, from the
 * start circle to the end circle.  The nominal path proceeds through the middle of the path through the maze.
 * <p>
 *
 * <tr valign="top">
 * <td>Show Minimum Path
 * <td>A checkbox item. If selected, a light grey line appears showing the minimum path through the maze, from
 * the start circle to the end circle.  The minimum path proceeds along the shortest error-free path through
 * the maze.
 * <p>
 *
 * <tr valign="top">
 * <td>Show Wall Hits
 * <td>A checkbox item. If selected, red patches appear at each location where the ball hits the wall of the path
 * when proceeding from a position inside the path.<p>
 *
 * <tr valign="top">
 * <td>Sound Alarm on Wall Hit
 * <td>A checkbox item. If selected, an audio alarm sound is emitted each time the ball hits a wall when proceeding
 * from a position inside the path.
 * <p>
 *
 * </table> </blockquote> <p>
 *
 * <h3>Program Operation</h3>
 * <p>
 *
 * After the setup parameters are set, the program is launched by clicking the OK button.  The following screen appears:
 * <p>
 *
 * <center>
 * <a href="TiltMaze-2.jpg"><img src="TiltMaze-2.jpg" width=200 alt="image"></a>
 * </center>
 * <p>
 *
 * The goal in TiltMaze is to control the movement of the virtual ball using device tilt.  Each trial begins by
 * positioning the ball inside the
 * green start circle for one second.  After doing this, an auditory beep is heard and the start circle turns grey.  The
 * end circle at the opposite corner of the display turns green.  The user's task is to manoeuvre the ball from the
 * start
 * circle, through the maze, to the end circle.  Doing so without hitting a path wall is an error-free
 * trial.  The
 * user should proceed as quickly and accurately as possible.
 * <p>
 *
 * Depending on the experiment conditions and other factors, user performance will vary.  Some users
 * will be fast, some slow.  And all users are likely to hit a path wall on occasion.  Feedback is critical.  When the
 * ball hits a path wall, a vibrotactile pulse is emitted.  An auditory alarm and visual indication may also occur,
 * if the corresponding setup parameter was selected.  The following figure shows the red patches indicating wall
 * hits (dashed line added):
 * <p>
 *
 * <center>
 * <a href="TiltMaze-3.jpg"><img src="TiltMaze-3.jpg" width=200 alt="image"></a>
 * </center>
 * <p>
 *
 * At the end of a sequence of trials, results appear in a dialog.  The results include the data on four user
 * performance variables (aka dependent variables).  These are
 * <p>
 *
 * <ul>
 * <li>Time &ndash; the total time for all the trials in the sequence
 * <li>Off-path time &ndash; the amount of time ball movement was not within the path (the percent time is also shown)
 * <li>Wall hits &ndash; the number of wall hits
 * <li>Movement efficiency &ndash; the ratio (%) of minimum movement distance divided by actual movement distance.
 * </ul>
 * <p>
 *
 * An example results dialog is shown below.<p>
 *
 * <center>
 * <a href="TiltMaze-4.jpg"><img src="TiltMaze-4.jpg" width=200 alt="image"></a>
 * </center>
 * <p>
 *
 * Note that movement efficiency is most meaningful for error-free trials.  For such trials, perfect movement
 * yields a movement efficiency of 100% and imperfect movement yields a movement efficiency < 100%.  For trials with
 * wall hits, movement efficiency can exceed 100%, for example, if the user just moved the ball directly from
 * the start circle to the end circle, smashing through the walls.
 * <p>
 *
 * <h3>Data Storage</h3>
 *
 * Data are also saved in a file for follow-up analyses.  After a sequence of trials, an "sd2" file is
 * created.  It contains summary data for each trial.  The file is saved in the public directory on Android devices
 * in a sub-directory called "TiltMazeData".  Below is an example file:
 * <p>
 *
 * <ul>
 * <li>sd2 &ndash; <a href="TiltMaze-P01-S01-B01-G01-C01-Velocity-G100.sd2">TiltMaze-P01-S01-B01-G01-C01-Velocity
 * -G100.sd2</a>
 * </ul>
 * <p>
 *
 * The data in the sd2 file are comma-delimited to facilitate importing in Excel or a statistics
 * application for follow-up analyses.  An example is shown below for the data in the sd2 file above.
 * <p>
 *
 * <center>
 * <a href="TiltMaze-5.jpg"><img src="TiltMaze-5.jpg" width=800 alt="image"></a>
 * </center>
 *
 *
 * <p>
 *
 * @author Scott MacKenzie, Catalina Constantin, 2014-2017
 */

public class TiltMazeActivity extends Activity implements SensorEventListener
{
    final static String MYDEBUG = "MYDEBUG"; // for Log.i messages
    final static int REFRESH_INTERVAL = 20; // milliseconds (screen refreshes @ 50 Hz)

    // int constants to setup a sensor mode (see DemoTiltMeter API for discussion)
    final static int ORIENTATION = 0;
    final static int ACCELEROMETER_ONLY = 1;
    final static int ACCELEROMETER_AND_MAGNETIC_FIELD = 2;

    final String DATA_DIRECTORY = "/TiltMazeData/";
    final String APP = "TiltMaze";
    final String SD2_HEADER = "App,Participant,Session,Block,Group,Condition,Order_Of_Control,Gain,Trial,"
            + "Time_Total(s),Time_Off_Path(s),Wall_Hits,Movement_Nominal,Movement_Actual,Movement_Off_Path," +
            "Movement_Efficiency(%)\n";
    final float RADIANS_TO_DEGREES = 57.2957795f;

    /*
     * Below are the alpha values for the low-pass filter. The four values are for the slowest
     * (NORMAL) to fastest (FASTEST) sampling rates, respectively. These values were determined by
     * trial and error. There is a trade-off. Generally, lower values produce smooth but sluggish
     * responses, while higher values produced jerky but fast responses.
     *
     * Furthermore, there is a difference by device, particularly for the FASTEST setting. For
     * example, the FASTEST sample rate is about 200 Hz on a Nexus 4 but only about 100 Hz on a
     * Samsung Galaxy Tab 10.1.
     *
     * Fiddle with these, as necessary.
     */
    final float[] ALPHA_VELOCITY = {0.99f, 0.80f, 0.40f, 0.15f};
    final float[] ALPHA_POSITION = {0.50f, 0.30f, 0.15f, 0.10f};

    // setup parameters
    String participantCode, sessionCode, groupCode, blockCode, conditionCode, orderOfControl;
    int gain;
    String gainLabel;
    int numberOfTrials;
    boolean showNominalPath, showMinimumPath, showWallHits, soundAlarmOnWallHit;
    RollingBallPanel rb;
    float alpha;
    int sensorMode;
    int defaultOrientation;
    float[] accValues = new float[3];
    float[] magValues = new float[3];
    float x, y, z, pitch, roll;
    ScreenRefreshTimer refreshScreen;

    boolean showReportWindow;
    String sd2Leader; // leader to identify conditions for data written to sd2 files.
    private BufferedWriter sd2;
    private File f2;
    private SensorManager sm;
    private Sensor sA, sM, sO;

    // Called when the activity is first created
    @SuppressWarnings("deprecation")
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        Bundle b = getIntent().getExtras();
        participantCode = b.getString(TiltMazeSetup.PARTICIPANT_CODE_KEY);
        sessionCode = b.getString(TiltMazeSetup.SESSION_CODE_KEY);
        // blockCode (auto)
        groupCode = b.getString(TiltMazeSetup.GROUP_CODE_KEY);
        conditionCode = b.getString(TiltMazeSetup.CONDITION_CODE_KEY);
        numberOfTrials = b.getInt(TiltMazeSetup.TRIALS_CODE_KEY);
        orderOfControl = b.getString(TiltMazeSetup.ORDER_OF_CONTROL_KEY);
        gain = b.getInt(TiltMazeSetup.GAIN_KEY);
        gainLabel = b.getString(TiltMazeSetup.GAIN_LABEL_KEY);
        showNominalPath = b.getBoolean(TiltMazeSetup.SHOW_NOMINAL_PATH_KEY);
        showMinimumPath = b.getBoolean(TiltMazeSetup.SHOW_MINIMUM_PATH_KEY);
        showWallHits = b.getBoolean(TiltMazeSetup.SHOW_WALL_HITS_KEY);
        soundAlarmOnWallHit = b.getBoolean(TiltMazeSetup.SOUND_ALARM_ON_WALL_HIT_KEY);

        // set alpha for low-pass filter (based on sampling rate and order of control)
        if (orderOfControl.equals("Velocity")) // velocity control
            alpha = ALPHA_VELOCITY[2]; // for GAME sampling rate
        else
            // position control
            alpha = ALPHA_POSITION[2]; // for GAME sampling rate

        // get this device's default orientation
        defaultOrientation = getDefaultDeviceOrientation();

        // force the UI to appear in the device's default orientation (and stay that way)
        if (defaultOrientation == Configuration.ORIENTATION_LANDSCAPE)
            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
        else
            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);

        // determine screen width, screen height, and pixel density
        DisplayMetrics dm = new DisplayMetrics();
        getWindowManager().getDefaultDisplay().getMetrics(dm);
        int w = dm.widthPixels;
        int h = dm.heightPixels;
        float pixelDensity = dm.density;

        showReportWindow = false;

        // ===================
        // File initialization
        // ===================

        // make a working directory (if necessary) to store data files
        File dataDirectory = new File(Environment.getExternalStorageDirectory() +
                DATA_DIRECTORY);
        if (!dataDirectory.exists() && !dataDirectory.mkdirs())
        {
            Log.e(MYDEBUG, "ERROR --> FAILED TO CREATE DIRECTORY: " + DATA_DIRECTORY);
            super.onDestroy(); // cleanup
            this.finish(); // terminate
        }

        /**
         * The following do-loop creates data files for output and a string sd2Leader to write to the sd2
         * output files.  Both the filenames and the sd2Leader are constructed by combining the setup parameters
         * so that the filenames and sd2Leader are unique and also reveal the conditions used for the block of input.
         *
         * The block code begins "B01" and is incremented on each loop iteration until an available
         * filename is found.  The goal, of course, is to ensure data files are not inadvertently overwritten.
         */
        int blockNumber = 0;
        do
        {
            ++blockNumber;
            blockCode = String.format(Locale.CANADA, "B%02d", blockNumber);
            String baseFilename = String.format(Locale.CANADA, "%s-%s-%s-%s-%s-%s-%s-G%d", APP, participantCode,
                    sessionCode, blockCode, groupCode, conditionCode, orderOfControl, gain);
            f2 = new File(dataDirectory, baseFilename + ".sd2");

            // also make a comma-delimited leader that will begin each data line written to the sd2 file
            sd2Leader = String.format(Locale.CANADA, "%s,%s,%s,%s,%s,%s,%s,%d", APP, participantCode, sessionCode,
                    blockCode, groupCode, conditionCode, orderOfControl, gain);
        } while (f2.exists());

        try
        {
            sd2 = new BufferedWriter(new FileWriter(f2));

            // output header in sd2 file
            sd2.write(SD2_HEADER, 0, SD2_HEADER.length());
            sd2.flush();

        } catch (IOException e)
        {
            Log.e(MYDEBUG, "ERROR OPENING DATA FILES! e=" + e.toString());
            super.onDestroy();
            this.finish();

        } // end file initialization

        // configure rolling ball panel, as per screen parameters and setup parameters
        rb = (RollingBallPanel)findViewById(R.id.rollingballpanel);
        rb.configure((float)w, (float)h, pixelDensity);
        rb.setNumberOfTrials(numberOfTrials);
        rb.setGain(gain);
        rb.setOrderOfControl(orderOfControl);
        rb.setShowNominalPath(showNominalPath);
        rb.setShowMinimumPath(showMinimumPath);
        rb.setShowWallHits(showWallHits);
        rb.setSoundAlarmOnWallHit(soundAlarmOnWallHit);
        rb.setBlockCode(blockCode);
        rb.setVibrator((Vibrator)getSystemService(Context.VIBRATOR_SERVICE));

        // get sensors
        sm = (SensorManager)getSystemService(SENSOR_SERVICE);
        sO = sm.getDefaultSensor(Sensor.TYPE_ORIENTATION); // supported on many devices
        sA = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); // supported on most devices
        sM = sm.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD); // null on many devices

        // setup the sensor mode (see API for discussion)
        if (sO != null)
        {
            sensorMode = ORIENTATION;
            sA = null;
            sM = null;
            Log.i(MYDEBUG, "Sensor mode: ORIENTATION");
        } else if (sA != null && sM != null)
        {
            sensorMode = ACCELEROMETER_AND_MAGNETIC_FIELD;
            Log.i(MYDEBUG, "Sensor mode: ACCELEROMETER_AND_MAGNETIC_FIELD");
        } else if (sA != null)
        {
            sensorMode = ACCELEROMETER_ONLY;
            Log.i(MYDEBUG, "Sensor mode: ACCELEROMETER_ONLY");
        } else
        {
            Log.i(MYDEBUG, "Can't run demo.  Requires Orientation sensor or Accelerometer");
            this.finish();
        }

        // NOTE: sensor listeners are registered in onResume

        // setup the screen refresh timer (screen updates every REFRESH_INTERVAL milliseconds)
        refreshScreen = new ScreenRefreshTimer(REFRESH_INTERVAL, REFRESH_INTERVAL);
        refreshScreen.start();
    }

    /*
     * Get the default orientation of the device. This is needed to correctly map the sensor data
     * for pitch and roll (see onSensorChanged).
     */
    public int getDefaultDeviceOrientation()
    {
        WindowManager windowManager = (WindowManager)getSystemService(WINDOW_SERVICE);
        Configuration config = getResources().getConfiguration();
        int rotation = windowManager.getDefaultDisplay().getRotation();

        if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) && config.orientation ==
                Configuration.ORIENTATION_LANDSCAPE)
                || ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) && config.orientation ==
                Configuration.ORIENTATION_PORTRAIT))
            return Configuration.ORIENTATION_LANDSCAPE;
        else
            return Configuration.ORIENTATION_PORTRAIT;
    }

    @Override
    protected void onResume()
    {
        super.onResume();
        sm.registerListener(this, sO, SensorManager.SENSOR_DELAY_GAME); // good enough!
        sm.registerListener(this, sA, SensorManager.SENSOR_DELAY_GAME);
        sm.registerListener(this, sM, SensorManager.SENSOR_DELAY_GAME);
    }

    @Override
    protected void onPause()
    {
        super.onPause();
        sm.unregisterListener(this);
    }

    @Override
    public void onAccuracyChanged(Sensor sensor, int accuracy)
    {
        // not needed but we need to provide an implementation anyway
    }

    @Override
    public void onSensorChanged(SensorEvent se)
    {
        // ===============================
        // DETERMINE DEVICE PITCH AND ROLL
        // ===============================

        switch (sensorMode)
        {
            // =======================================================
            // DETERMINE DEVICE PITCH AND ROLL (VARIES BY SENSOR MODE)
            // =======================================================

            // ---------------------------------------------------------------------------------------------
            case ORIENTATION:
                pitch = se.values[1];
                roll = se.values[2];
                break;

            // ---------------------------------------------------------------------------------------------
            case ACCELEROMETER_AND_MAGNETIC_FIELD:
                // smooth the sensor values using a low-pass filter
                if (se.sensor.getType() == Sensor.TYPE_ACCELEROMETER)
                    accValues = lowPass(se.values.clone(), accValues, alpha); // filtered
                if (se.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD)
                    magValues = lowPass(se.values.clone(), magValues, alpha); // filtered

                if (accValues != null && magValues != null)
                {
                    // compute pitch and roll
                    float R[] = new float[9];
                    float I[] = new float[9];
                    boolean success = SensorManager.getRotationMatrix(R, I, accValues, magValues);
                    if (success) // see SensorManager API
                    {
                        float[] orientation = new float[3];
                        SensorManager.getOrientation(R, orientation); // see getOrientation API
                        pitch = orientation[1] * RADIANS_TO_DEGREES;
                        roll = -orientation[2] * RADIANS_TO_DEGREES;
                    }
                }
                break;

            // ---------------------------------------------------------------------------------------------
            case ACCELEROMETER_ONLY:

				/*
                 * Use this mode if the device has an accelerometer but no magnetic field sensor and
				 * no orientation sensor (e.g., HTC Desire C, Asus MeMOPad). This algorithm doesn't
				 * work quite as well, unfortunately. See...
				 *
				 * http://www.hobbytronics.co.uk/accelerometer-info
				 */

                // smooth the sensor values using a low-pass filter
                if (se.sensor.getType() == Sensor.TYPE_ACCELEROMETER)
                    accValues = lowPass(se.values.clone(), accValues, alpha);

                x = accValues[0];
                y = accValues[1];
                z = accValues[2];
                pitch = -(float)Math.atan(y / Math.sqrt(x * x + z * z)) * RADIANS_TO_DEGREES;
                roll = (float)Math.atan(x / Math.sqrt(y * y + z * z)) * RADIANS_TO_DEGREES;
                break;

        }
    }

    /*
     * Low pass filter. The algorithm requires tracking only two numbers - the prior number and the
     * new number. There is a time constant "alpha" which determines the amount of smoothing. Alpha
     * is like a "weight" or "momentum". It determines the effect of the new value on the current
     * smoothed value.
     *
     * A lower alpha means more smoothing. NOTE: 0 <= alpha <= 1.
     *
     * See...
     *
     * http://blog.thomnichols.org/2011/08/smoothing-sensor-data-with-a-low-pass-filter
     */
    protected float[] lowPass(float[] input, float[] output, float alpha)
    {
        for (int i = 0; i < input.length; i++)
            output[i] = output[i] + alpha * (input[i] - output[i]);
        return output;
    }

    // Shows the Report window and allows for exit or restart of the application
    private void showReport()
    {
        float totalTimeForTrial = 0f;
        float totalTimeOffPath = 0f;
        int totalWallHits = 0;
        float totalMovementNominal = 0f;
        float totalMovementActual = 0f;

        for (int i = 0; i < rb.data.length; ++i)
        {
            totalTimeForTrial += rb.data[i].timeForTrialResult;
            totalTimeOffPath += rb.data[i].timeOffPathResult;
            totalWallHits += rb.data[i].wallHitsResult;
            totalMovementNominal += rb.data[i].movementNominalResult;
            totalMovementActual += rb.data[i].movementActualResult;
        }

        // bundle up parameters to pass on to results activity
        Bundle b = new Bundle();
        b.putString("block", this.blockCode);
        b.putInt("numTrials", rb.numberOfTrials);
        b.putFloat("time", totalTimeForTrial);
        b.putFloat("timeOffPath", totalTimeOffPath);
        b.putInt("wallHits", totalWallHits);
        b.putFloat("movementEfficiency", totalMovementNominal / totalMovementActual * 100.0f);

        // start results activity
        Intent i = new Intent(getApplicationContext(), ResultsActivity.class);
        i.putExtras(b);
        startActivity(i);
        finish();
    }

    // Record the user results
    private void recordResults()
    {
        StringBuilder sd2Data = new StringBuilder(1000);

        for (int i = 0; i < rb.data.length; ++i)
        {
            sd2Data.append(String.format(Locale.CANADA, "%s,", sd2Leader));
            sd2Data.append(String.format(Locale.CANADA, "%d,", rb.data[i].trialNumberResult));
            sd2Data.append(String.format(Locale.CANADA, "%f,", rb.data[i].timeForTrialResult));
            sd2Data.append(String.format(Locale.CANADA, "%f,", rb.data[i].timeOffPathResult));
            sd2Data.append(String.format(Locale.CANADA, "%d,", rb.data[i].wallHitsResult));
            sd2Data.append(String.format(Locale.CANADA, "%.1f,", rb.data[i].movementNominalResult));
            sd2Data.append(String.format(Locale.CANADA, "%.1f,", rb.data[i].movementActualResult));
            sd2Data.append(String.format(Locale.CANADA, "%.1f,", rb.data[i].movementOffPathResult));
            sd2Data.append(String.format(Locale.CANADA, "%.1f\n", rb.data[i].movementEfficiencyResult * 100f));
        }
        try
        {
            sd2.write(sd2Data.toString(), 0, sd2Data.length());
        } catch (IOException e)
        {
            Log.e(MYDEBUG, "ERROR WRITING TO DATA FILE! e=" + e.toString());
        }

        doDone();
    }

    // Closes the file and makes sure it's visible
    private void doDone()
    {
        try
        {
            sd2.close();
            MediaScannerConnection.scanFile(this, new String[] {f2.getAbsolutePath()}, null, null);

        } catch (IOException e)
        {
            Log.e(MYDEBUG, "ERROR CLOSING DATA FILE! e=" + e);
        }
    }

    /*
     * Screen updates are done in onFinish which executes every REFRESH_INTERVAL milliseconds
     */
    public class ScreenRefreshTimer extends CountDownTimer
    {
        ScreenRefreshTimer(long millisInFuture, long countDownInterval)
        {
            super(millisInFuture, countDownInterval);
        }

        @Override
        public void onTick(long millisUntilFinished)
        {
        }

        @Override
        public void onFinish()
        {
            // get the device's tilt magnitude and angle (from the sensor's pitch and roll)
            float tiltMagnitude = (float)Math.sqrt(pitch * pitch + roll * roll);
            float tiltAngle = tiltMagnitude == 0f ? 0f : (float)Math.asin(roll / tiltMagnitude) * RADIANS_TO_DEGREES;

            // adjust if necessary
            if (pitch > 0 && roll > 0)
                tiltAngle = 360f - tiltAngle;
            else if (pitch > 0 && roll < 0)
                tiltAngle = -tiltAngle;
            else if (pitch < 0 && roll > 0)
                tiltAngle = tiltAngle + 180f;
            else if (pitch < 0 && roll < 0)
                tiltAngle = tiltAngle + 180f;

            // check the ball panel to see if we are done
            if (rb.done && !showReportWindow)
            {
                // Done! show/store results
                showReport();
                showReportWindow = true;
                recordResults();
            } else
            {
                /*
                 * Not done! update the ball position, etc. This will invalidate the ball panel to
				 * force a redraw. Restart the timer to prepare for next update.
				 */
                rb.updateBallPosition(pitch, roll, tiltAngle, tiltMagnitude);
                this.start();
            }
        }
    }
}