ac.robinson.paperchains.PaperChainsActivity.java Source code

Java tutorial

Introduction

Here is the source code for ac.robinson.paperchains.PaperChainsActivity.java

Source

/*
 * Copyright (c) 2014 Simon Robinson
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package ac.robinson.paperchains;

import android.app.Activity;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.Path;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.preference.PreferenceManager;
import android.support.v7.app.ActionBar;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnticipateInterpolator;
import android.view.animation.LinearInterpolator;
import android.view.animation.OvershootInterpolator;
import android.view.animation.RotateAnimation;
import android.widget.RelativeLayout;
import android.widget.Toast;

import com.github.lassana.recorder.AudioRecorder;
import com.loopj.android.http.AsyncHttpClient;
import com.loopj.android.http.JsonHttpResponseHandler;
import com.loopj.android.http.RequestParams;
import com.nineoldandroids.animation.AnimatorSet;
import com.nineoldandroids.animation.ObjectAnimator;
import com.sonyericsson.zoom.DynamicZoomControl;
import com.sonyericsson.zoom.LongPressZoomListener;
import com.soundcloud.api.Env;
import com.soundcloud.api.Token;

import org.apache.http.Header;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;

import ac.robinson.dualqrscanner.CodeParameters;
import ac.robinson.dualqrscanner.DecoderActivity;
import ac.robinson.dualqrscanner.ImageParameters;
import ac.robinson.dualqrscanner.QRImageParser;
import ac.robinson.dualqrscanner.ViewfinderView;
import ac.robinson.dualqrscanner.camera.CameraUtilities;

public class PaperChainsActivity extends DecoderActivity {

    private static final int BUTTON_ANIMATION_DURATION = 250; // animation (and removal) time for recording interface

    private static com.soundcloud.playerapi.ApiWrapper sSoundCloudPlayerApiWrapper;
    private static com.soundcloud.api.ApiWrapper sSoundCloudUploaderApiWrapper;

    private static final int MODE_CAPTURE = 0;
    private static final int MODE_LISTEN = 1;
    private static final int MODE_ADD = 2;
    private static final int MODE_IMAGE_ONLY = 3;

    private static final int SOUNDCLOUD_LOGIN_RESULT = 1;

    private static final String BASE_URL = "http://www.enterise.info/";
    private static final String CODE_SERVER_URL = BASE_URL + "codemaker/pages.php";
    public static final String SOUNDCLOUD_LOGIN_URL = BASE_URL + "paperchains/soundcloud.html";

    private PaperChainsView mImageView;
    private DynamicZoomControl mZoomControl;
    private LongPressZoomListener mZoomListener;

    private ImageParameters mImageParameters;

    private final ArrayList<AudioAreaHolder> mAudioAreas = new ArrayList<>();
    private MediaPlayer mAudioPlayer;
    private AudioRecorder mAudioRecorder;
    private Rect mCurrentAudioRect;

    private int mCurrentMode;
    private String mPageId;

    private boolean mAudioAreasLoaded = false;
    private boolean mImageParsed = false;

    private AudioRecorderCircleButton mRecordButton;
    private AudioRecorderCircleButton mPlayButton;
    private AudioRecorderCircleButton mDeleteButton;
    private AudioRecorderCircleButton mSaveButton;

    private RotateAnimation mRotateAnimation;

    private class AudioAreaHolder {
        public final long soundCloudId;
        public final Rect serverRect;
        public Rect imageRect;

        public AudioAreaHolder(long soundCloudId, Rect serverRect) {
            this.soundCloudId = soundCloudId;
            this.serverRect = serverRect;
        }

        public void setImageRect(Rect imageRect) {
            this.imageRect = imageRect;
        }
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        if (!CameraUtilities.getIsCameraAvailable(getPackageManager())) {
            Toast.makeText(PaperChainsActivity.this, getString(R.string.hint_no_camera), Toast.LENGTH_SHORT).show();
            finish();
            return;
        }

        getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
        setContentView(R.layout.main);

        setViews(R.id.viewfinder_view, R.id.preview_view, R.id.image_view);
        setResizeImageToView(false); // we want the high quality image

        setVolumeControlStream(AudioManager.STREAM_MUSIC);

        // set up action bar
        ActionBar actionBar = getSupportActionBar();
        actionBar.setTitle(R.string.title_activity_capture);
        actionBar.setDisplayShowTitleEnabled(true);

        int resultPointColour = getResources().getColor(R.color.accent);
        ((ViewfinderView) findViewById(R.id.viewfinder_view)).setResultPointColour(resultPointColour);

        // set up SoundCloud API wrappers (without a user token - for playback only, initially)
        setupSoundCloudApiWrappers();

        mCurrentMode = MODE_CAPTURE;

        // set up a zoomable view for the photo
        mImageView = (PaperChainsView) findViewById(R.id.image_view);
        mZoomControl = new DynamicZoomControl();
        mImageView.setZoomState(mZoomControl.getZoomState());
        mZoomControl.setAspectQuotient(mImageView.getAspectQuotient());
        mZoomListener = new LongPressZoomListener(PaperChainsActivity.this);
        mZoomListener.setZoomControl(mZoomControl);

        // set up buttons/handlers
        mImageView.setOnTouchListener(mZoomListener);
        mImageView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                onImageClick();
            }
        });
        mImageView.setScribbleCallback(new PaperChainsView.ScribbleCallback() {
            @Override
            public void scribbleCompleted(Path scribble) {
                processScribble(scribble);
            }
        });

        mRecordButton = (AudioRecorderCircleButton) findViewById(R.id.record_button);
        mRecordButton.setOnClickListener(mRecordButtonListener);

        mPlayButton = (AudioRecorderCircleButton) findViewById(R.id.play_button);
        mPlayButton.setOnClickListener(mPlayButtonListener);

        mDeleteButton = (AudioRecorderCircleButton) findViewById(R.id.delete_button);
        mDeleteButton.setOnClickListener(mDeleteButtonListener);

        mSaveButton = (AudioRecorderCircleButton) findViewById(R.id.save_button);
        mSaveButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                saveAudio();
            }
        });

        // set up animation for the play/save buttons
        mRotateAnimation = new RotateAnimation(0f, 360f, Animation.RELATIVE_TO_SELF, 0.5f,
                Animation.RELATIVE_TO_SELF, 0.5f);
        mRotateAnimation.setDuration(BUTTON_ANIMATION_DURATION);
        mRotateAnimation.setInterpolator(new LinearInterpolator());
        mRotateAnimation.setRepeatCount(Animation.INFINITE);
        mRotateAnimation.setRepeatMode(Animation.RESTART);
    }

    @Override
    public void onPause() {
        super.onPause();
        resetAudioPlayer();
        if (mAudioRecorder != null) {
            if (mAudioRecorder.isRecording()) {
                mAudioRecorder.pause(null);
            }
            // note: not set to null just in case they want to resume recording when returning
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        if (mCurrentMode == MODE_CAPTURE) {
            // no menus or interaction if we haven't got a page yet
            return super.onCreateOptionsMenu(menu);
        }

        getMenuInflater().inflate(R.menu.menu, menu);
        switch (mCurrentMode) {
        case MODE_LISTEN:
            menu.findItem(R.id.action_listen).setVisible(false);
            break;

        case MODE_ADD:
            menu.findItem(R.id.action_add_audio).setVisible(false);
            break;

        case MODE_IMAGE_ONLY:
            menu.findItem(R.id.action_listen).setVisible(false);
            menu.findItem(R.id.action_add_audio).setVisible(false);
            break;
        }
        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
        case R.id.action_add_audio:
            switchMode(MODE_ADD);
            return true;

        case R.id.action_listen:
            switchMode(MODE_LISTEN);
            return true;

        case R.id.action_rescan:
            switchMode(MODE_CAPTURE);
            return true;

        default:
            return super.onOptionsItemSelected(item);
        }
    }

    @Override
    protected void onDecodeCompleted() {
        // Toast.makeText(TicQRActivity.this, "Decode completed; now taking picture", Toast.LENGTH_SHORT).show();
    }

    @Override
    protected void onPageIdFound(final String id) {
        // Toast.makeText(TicQRActivity.this, "Page ID found", Toast.LENGTH_SHORT).show();

        new AsyncHttpClient().get(CODE_SERVER_URL, new RequestParams("lookup", id), new JsonHttpResponseHandler() {
            private void handleFailure(int reason) {
                // nothing we can do except browse the image
                switchMode(MODE_IMAGE_ONLY);
                Toast.makeText(PaperChainsActivity.this, getString(reason), Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onSuccess(int statusCode, Header[] headers, JSONObject response) {
                try {
                    if ("ok".equals(response.getString("status"))) {
                        JSONArray areas = response.getJSONArray("audioAreas");
                        if (areas != null && !areas.isNull(0)) {
                            for (int i = 0; i < areas.length(); i++) {
                                JSONObject jsonBox = areas.getJSONObject(i);
                                mAudioAreas.add(new AudioAreaHolder(jsonBox.getLong("soundCloudId"),
                                        new Rect(jsonBox.getInt("left"), jsonBox.getInt("top"),
                                                jsonBox.getInt("right"), jsonBox.getInt("bottom"))));
                            }
                        }

                        mPageId = id;
                        mAudioAreasLoaded = true;
                        if (mImageParsed) {
                            addAudioRects();
                            switchMode(MODE_LISTEN);
                        }
                    } else {
                        handleFailure(R.string.hint_json_error);
                    }
                } catch (JSONException e) {
                    handleFailure(R.string.hint_json_error);
                }
            }

            @Override
            public void onFailure(int statusCode, Header[] headers, Throwable throwable, JSONObject errorResponse) {
                handleFailure(R.string.hint_connection_error);
            }
        });
    }

    @Override
    protected void onPictureError() {
        // note: an automatic rescan is started whenever this occurs, so this is mainly designed for, e.g.,
        // counting a large number of errors and prompting the user to reposition the camera
        // Toast.makeText(TicQRActivity.this, "Picture error", Toast.LENGTH_SHORT).show();
    }

    @Override
    protected void onPictureCompleted(Bitmap parsedBitmap, ImageParameters imageParameters,
            CodeParameters codeParameters) {
        // Toast.makeText(TicQRActivity.this, "Picture completed", Toast.LENGTH_SHORT).show();

        mImageView.setImage(parsedBitmap);

        mZoomControl.getZoomState().setPanX(0.5f);
        mZoomControl.getZoomState().setPanY(0.5f);
        mZoomControl.getZoomState().setZoom(1f);
        mZoomControl.startFling(0, 0); // because stopFling doesn't work...

        mImageView.setVisibility(View.VISIBLE);

        mImageParameters = imageParameters;
        // mCodeParameters = codeParameters; // not needed for this application

        mImageParsed = true;
        if (mAudioAreasLoaded) {
            addAudioRects();
            switchMode(MODE_LISTEN);
        }
    }

    private void switchMode(int newMode) {
        // TODO: check we're not recording/saving audio before doing this? (currently we allow it somewhat hackily)
        switch (newMode) {
        case MODE_ADD:
            resetAudioPlayer();
            getSupportActionBar().setTitle(getString(R.string.title_activity_add));
            mZoomListener.setPanZoomEnabled(false);
            mImageView.setScribbleEnabled(true);
            mImageView.setClickable(true);
            mImageView.setDrawAudioRectsEnabled(true);
            mCurrentMode = MODE_ADD;
            supportInvalidateOptionsMenu();
            break;

        case MODE_LISTEN:
            resetRecordingInterface(); // first as it also sets the the activity title
            getSupportActionBar().setTitle(getString(R.string.title_activity_explore));
            mZoomListener.setPanZoomEnabled(true);
            mImageView.setScribbleEnabled(false);
            mImageView.setClickable(true);
            mImageView.setDrawAudioRectsEnabled(false);
            mCurrentMode = MODE_LISTEN;
            supportInvalidateOptionsMenu();
            break;

        case MODE_CAPTURE:
            // reset our configuration and set up for rescanning
            mAudioAreas.clear();
            mImageView.clearAudioAreaRects();

            mAudioAreasLoaded = false;
            mImageParsed = false;
            mImageView.setVisibility(View.INVISIBLE); // must be invisible (not gone) as we need its dimensions

            resetAudioPlayer(); // TODO: fix odd intermittent rotation issue with the play button after rescanning
            resetRecordingInterface(); // first as it also sets the the activity title
            getSupportActionBar().setTitle(R.string.title_activity_capture);
            mZoomListener.setPanZoomEnabled(true);
            mImageView.setScribbleEnabled(false);
            mImageView.setClickable(false);
            mImageView.setDrawAudioRectsEnabled(false);
            mCurrentMode = MODE_CAPTURE;
            supportInvalidateOptionsMenu();

            requestScanResume();
            break;

        case MODE_IMAGE_ONLY:
            // allow image exploration, but no listening
            getSupportActionBar().setTitle(getString(R.string.title_activity_image_only));
            mZoomListener.setPanZoomEnabled(true);
            mCurrentMode = MODE_IMAGE_ONLY;
            supportInvalidateOptionsMenu();
            break;
        }
    }

    private void addAudioRects() {
        for (AudioAreaHolder holder : mAudioAreas) {
            // convert grid-based coordinates to image-based coordinates, accounting for image rotation/inversion by
            // making sure to use the min/max values of each coordinate
            Rect rect = holder.serverRect;
            PointF leftTop = QRImageParser.getImagePosition(mImageParameters, new PointF(rect.left, rect.top));
            PointF rightBottom = QRImageParser.getImagePosition(mImageParameters,
                    new PointF(rect.right, rect.bottom));
            RectF displayRect = new RectF(Math.min(leftTop.x, rightBottom.x), Math.min(leftTop.y, rightBottom.y),
                    Math.max(rightBottom.x, leftTop.x), Math.max(leftTop.y, rightBottom.y));
            Rect imageRect = new Rect();
            displayRect.roundOut(imageRect);

            holder.setImageRect(imageRect);
            mImageView.addAudioAreaRect(imageRect);
        }
    }

    private void onImageClick() {
        resetAudioPlayer();
        supportInvalidateOptionsMenu();

        Point touchPoint = mImageView.screenPointToImagePoint(mZoomListener.getLastTouchPoint());
        boolean rectTouched = false;
        for (AudioAreaHolder holder : mAudioAreas) {
            Rect rect = holder.imageRect;
            if (rect.contains(touchPoint.x, touchPoint.y)) {
                rectTouched = true;
                if (mCurrentMode == MODE_ADD) {
                    if (rect.equals(mCurrentAudioRect)) {
                        mRecordButton.performClick();
                    } else {
                        resetRecordingInterface();
                    }
                    break;
                } else if (mCurrentMode == MODE_LISTEN) {
                    // TODO: handle overlapping rectangles (pop up several buttons as options?)
                    initialisePlaybackButton(mZoomListener.getLastTouchPoint());
                    mImageView.setDragCallback(new PaperChainsView.DragCallback() {
                        @Override
                        public void dragStarted() {
                            resetAudioPlayer(); // we don't update the button position on drag; for now, just stop play
                        }
                    });
                    new SoundCloudUrlFetcherTask(PaperChainsActivity.this, sSoundCloudPlayerApiWrapper)
                            .execute(holder.soundCloudId);
                    break;
                }
            }
        }

        // remove a rect and re-enable scribbling when touching outside in add mode
        if (!rectTouched) {
            switch (mCurrentMode) {
            case MODE_ADD:
                resetRecordingInterface();
                break;
            case MODE_LISTEN:
                resetAudioPlayer();
                break;
            }
        }
    }

    private void processScribble(Path scribble) {
        try {
            // the file we're given via createTempFile is unplayable, but the name creation routine is useful...
            File outputFile = File.createTempFile(getString(R.string.app_name), ".mp4", getCacheDir());
            String outputFilePath = outputFile.getAbsolutePath();
            if (outputFile.delete()) {
                // get the bounding box and add to our list
                RectF scribbleBox = new RectF();
                scribble.computeBounds(scribbleBox, true);
                Rect audioArea = new Rect();
                scribbleBox.roundOut(audioArea);
                int scribbleWidth = Math
                        .round(getResources().getDimensionPixelSize(R.dimen.scribble_stroke_width) / 2f); // expand to include stroke width (half either side of line)
                audioArea.inset(-scribbleWidth, -scribbleWidth);

                // initialise recording
                resetRecordingInterface();
                mAudioRecorder = AudioRecorder.build(PaperChainsActivity.this, outputFilePath);

                mCurrentAudioRect = audioArea;
                mImageView.addAudioAreaRect(audioArea);
                mImageView.setScribbleEnabled(false);

                // position the recording buttons
                PointF centrePoint = mImageView
                        .imagePointToScreenPoint(new Point(audioArea.centerX(), audioArea.centerY()));
                initialiseRecordingButtons(centrePoint);
            } else {
                Toast.makeText(PaperChainsActivity.this, getString(R.string.audio_recording_setup_error),
                        Toast.LENGTH_SHORT).show();
            }

        } catch (IOException | IllegalArgumentException e) {
            Toast.makeText(PaperChainsActivity.this, getString(R.string.audio_recording_setup_error),
                    Toast.LENGTH_SHORT).show();
        }
    }

    private final View.OnClickListener mRecordButtonListener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (mAudioRecorder != null) {
                if (mAudioRecorder.isReady() || mAudioRecorder.isPaused()) {
                    if (mPlayButton.getVisibility() == View.VISIBLE) {
                        animateRecordingInterface(-1, mRecordButton); // -1 = in; hide other controls if visible
                    }

                    mAudioRecorder.start(new AudioRecorder.OnStartListener() {
                        @Override
                        public void onStarted() {
                            mImageView.setClickable(false);
                            mRecordButton.setRecorder(mAudioRecorder);
                            mRecordButton.setImageResource(R.drawable.ic_pause_white_24dp);
                        }

                        @Override
                        public void onException(Exception e) {
                            mImageView.setClickable(true);
                            Toast.makeText(PaperChainsActivity.this,
                                    getString(R.string.audio_recording_setup_error), Toast.LENGTH_SHORT).show();
                        }
                    });

                } else if (mAudioRecorder.isRecording()) {
                    mRecordButton.setRecorder(null); // set before actually pausing to avoid concurrency issues
                    mAudioRecorder.pause(new AudioRecorder.OnPauseListener() {
                        @Override
                        public void onPaused(String activeRecordFileName) {
                            mRecordButton.setImageResource(R.drawable.ic_mic_white_24dp);
                            animateRecordingInterface(1, null); // 1 = animate out
                        }

                        @Override
                        public void onException(Exception e) {
                            mImageView.setClickable(true);
                            Toast.makeText(PaperChainsActivity.this,
                                    getString(R.string.audio_recording_pause_error), Toast.LENGTH_SHORT).show();
                        }
                    });
                } else {
                    Toast.makeText(PaperChainsActivity.this, getString(R.string.audio_recording_setup_error),
                            Toast.LENGTH_SHORT).show();
                }
            } else {
                Toast.makeText(PaperChainsActivity.this, getString(R.string.audio_recording_setup_error),
                        Toast.LENGTH_SHORT).show();
            }
        }
    };

    private final View.OnClickListener mPlayButtonListener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (mAudioPlayer != null) {
                if (mAudioPlayer.isPlaying()) {
                    mAudioPlayer.pause();
                    mPlayButton.setImageResource(R.drawable.ic_play_arrow_white_24dp);
                } else {
                    mAudioPlayer.start();
                    mPlayButton.setImageResource(R.drawable.ic_pause_white_24dp);
                }
            } else if (mAudioRecorder != null && mAudioRecorder.isPaused()) {
                streamAudio(mAudioRecorder.getRecordFileName());
            }
        }
    };

    private final View.OnClickListener mDeleteButtonListener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            animateRecordingInterface(-1, mDeleteButton); // -1 = animate in
            delayedResetRecordingInterface();
        }
    };

    private void setupSoundCloudApiWrappers() {
        // we use two versions of the SoundCloud API as one works for playback; the other works for upload
        // (neither works for both without editing)
        String id = getString(R.string.soundcloud_client_id);
        String secret = getString(R.string.soundcloud_client_secret);
        sSoundCloudPlayerApiWrapper = new com.soundcloud.playerapi.ApiWrapper(id, secret, null, null);
        sSoundCloudUploaderApiWrapper = new com.soundcloud.api.ApiWrapper(id, secret, null, null, Env.LIVE);
    }

    private String getSoundCloudAccessToken() {
        SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(PaperChainsActivity.this);
        return settings.getString(getString(R.string.pref_soundcloud_access_token), null);
    }

    private void setSoundCloudAccessToken(String token) {
        SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(PaperChainsActivity.this);
        SharedPreferences.Editor editor = settings.edit();
        editor.putString(getString(R.string.pref_soundcloud_access_token), token);
        editor.commit();
    }

    private void saveAudio() {
        mSaveButton.setClickable(false); // don't allow clicks regardless of status

        String accessToken = getSoundCloudAccessToken();
        if (TextUtils.isEmpty(accessToken)) {
            // TODO: if they *do* have SoundCloud installed, use the in-app token method
            startActivityForResult(new Intent(PaperChainsActivity.this, SoundCloudLoginActivity.class),
                    SOUNDCLOUD_LOGIN_RESULT);
        } else {
            animateRecordingInterface(-1, mSaveButton); // -1 = animate in
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
                    mSaveButton.setImageResource(R.drawable.ic_refresh_white_24dp);
                    mSaveButton.startAnimation(mRotateAnimation);
                }
            }, BUTTON_ANIMATION_DURATION); // delayed so that the rotation doesn't interfere with the inwards animation

            new SoundCloudUploadTask(PaperChainsActivity.this, sSoundCloudUploaderApiWrapper,
                    new Token(accessToken, null, Token.SCOPE_NON_EXPIRING), mPageId, new Rect(mCurrentAudioRect))
                            .execute(mAudioRecorder.getRecordFileName());
        }
    }

    public void audioSaveFailed(int reason) {
        mSaveButton.clearAnimation();
        mSaveButton.setImageResource(R.drawable.ic_done_white_24dp);
        animateRecordingInterface(1, null); // 1 = animate out
        mSaveButton.setClickable(true);
        int messageId = reason == -1 ? R.string.hint_audio_save_failed : reason;
        Toast.makeText(PaperChainsActivity.this, getString(messageId), Toast.LENGTH_SHORT).show();
    }

    public void audioSaveCompleted(final Rect audioRect, final long trackId) {
        // convert back to grid-based coordinates
        final PointF rectLeftTop = QRImageParser.getGridPosition(mImageParameters,
                new PointF(audioRect.left, audioRect.top));
        final PointF rectRightBottom = QRImageParser.getGridPosition(mImageParameters,
                new PointF(audioRect.right, audioRect.bottom));

        // account for image rotation/inversion by making sure to use the min/max values of each coordinate
        int left = Math.round(rectLeftTop.x);
        int top = Math.round(rectLeftTop.y);
        int right = Math.round(rectRightBottom.x);
        int bottom = Math.round(rectRightBottom.y);
        final int leftmost = Math.min(left, right);
        final int topmost = Math.min(top, bottom);
        final int rightmost = Math.max(left, right);
        final int bottommost = Math.max(top, bottom);

        RequestParams params = new RequestParams("newaudio", 1); // 1 reserved for possible future use as box ID
        params.put("left", leftmost);
        params.put("top", topmost);
        params.put("right", rightmost);
        params.put("bottom", bottommost);
        params.put("soundCloudId", trackId);
        params.put("pageId", mPageId);

        // update on the server
        new AsyncHttpClient().get(CODE_SERVER_URL, params, new JsonHttpResponseHandler() {
            @Override
            public void onSuccess(int statusCode, Header[] headers, JSONObject response) {
                try {
                    if ("ok".equals(response.getString("status"))) {
                        AudioAreaHolder holder = new AudioAreaHolder(trackId,
                                new Rect(leftmost, topmost, rightmost, bottommost));
                        holder.setImageRect(new Rect(audioRect));
                        mAudioAreas.add(holder);
                        mImageView.addAudioAreaRect(holder.imageRect);

                        mSaveButton.clearAnimation();
                        mSaveButton.setImageResource(R.drawable.ic_done_white_24dp);
                        delayedResetRecordingInterface();
                    } else {
                        audioSaveFailed(R.string.hint_audio_save_failed);
                    }
                } catch (JSONException e) {
                    audioSaveFailed(R.string.hint_audio_save_failed);
                }
            }

            @Override
            public void onFailure(int statusCode, Header[] headers, Throwable throwable, JSONObject errorResponse) {
                audioSaveFailed(R.string.hint_audio_save_failed);
            }
        });
    }

    private void initialisePlaybackButton(PointF centrePoint) {
        mPlayButton.setLayoutParams(
                getLayoutParamsForButtonPosition(centrePoint, mPlayButton.getWidth(), mPlayButton.getHeight(),
                        mImageView.getLeft(), mImageView.getTop(), mImageView.getRight(), mImageView.getBottom()));
        mPlayButton.setImageResource(R.drawable.ic_pause_white_24dp); // we know there is audio at this location
        mPlayButton.setVisibility(View.VISIBLE);
    }

    private void initialiseRecordingButtons(PointF centrePoint) {
        int parentLeft = mImageView.getLeft();
        int parentTop = mImageView.getTop();
        int parentRight = mImageView.getRight();
        int parentBottom = mImageView.getBottom();
        mRecordButton.setLayoutParams(getLayoutParamsForButtonPosition(centrePoint, mRecordButton.getWidth(),
                mRecordButton.getHeight(), parentLeft, parentTop, parentRight, parentBottom));
        mRecordButton.setVisibility(View.VISIBLE);

        // position the control buttons in anticipation of recording completion
        RelativeLayout.LayoutParams controlLayoutParams = getLayoutParamsForButtonPosition(centrePoint,
                mPlayButton.getWidth(), mPlayButton.getHeight(), parentLeft, parentTop, parentRight, parentBottom);
        mPlayButton.setLayoutParams(controlLayoutParams);
        mDeleteButton.setLayoutParams(controlLayoutParams);
        mSaveButton.setLayoutParams(controlLayoutParams);
        mPlayButton.setImageResource(R.drawable.ic_play_arrow_white_24dp); // in case it was used for playback
        mPlayButton.setVisibility(View.INVISIBLE);
        mDeleteButton.setVisibility(View.INVISIBLE);
        mSaveButton.setVisibility(View.INVISIBLE);

        getSupportActionBar().setTitle(getString(R.string.title_activity_record));
    }

    private RelativeLayout.LayoutParams getLayoutParamsForButtonPosition(PointF buttonPosition, int buttonWidth,
            int buttonHeight, int parentLeft, int parentTop, int parentRight, int parentBottom) {
        RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(buttonWidth, buttonHeight);
        layoutParams.leftMargin = parentLeft + Math.round(buttonPosition.x - (buttonWidth / 2f));
        layoutParams.topMargin = parentTop + Math.round(buttonPosition.y - (buttonHeight / 2f));

        // need to set negative margins for when the button is close to the edges (its size would be changed otherwise)
        int rightPosition = layoutParams.leftMargin + buttonWidth;
        if (rightPosition > parentRight) {
            layoutParams.rightMargin = parentRight - rightPosition;
        }
        int bottomPosition = layoutParams.topMargin + buttonHeight;
        if (bottomPosition > parentBottom) {
            layoutParams.bottomMargin = parentBottom - bottomPosition;
        }
        return layoutParams;
    }

    private void animateRecordingInterface(int direction, View keyView) {
        mPlayButton.setVisibility(View.VISIBLE);
        mDeleteButton.setVisibility(View.VISIBLE);
        mSaveButton.setVisibility(View.VISIBLE);

        // animate the control buttons out to be equally spaced around the record button
        float buttonOffset = -mPlayButton.getWidth();
        PointF centre = new PointF(0, 0);
        PointF startingPoint = new PointF(0, buttonOffset);
        double radRot = Math.toRadians(-120);
        double cosRot = Math.cos(radRot);
        double sinRot = Math.sin(radRot);
        QRImageParser.rotatePoint(startingPoint, centre, cosRot, sinRot);
        float leftX = startingPoint.x;
        float leftY = startingPoint.y;
        QRImageParser.rotatePoint(startingPoint, centre, cosRot, sinRot);
        float rightX = startingPoint.x;
        float rightY = startingPoint.y;

        RelativeLayout parent;
        AnimatorSet buttonAnimator = new AnimatorSet();
        switch (direction) {
        case 1: // out
            // on an outward animation, we want the three minor buttons to have priority so the record button's
            // larger click area doesn't capture their clicks
            mPlayButton.bringToFront();
            mDeleteButton.bringToFront();
            mSaveButton.bringToFront();
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
                // need to manually re-layout before KitKat
                parent = (RelativeLayout) mPlayButton.getParent();
                parent.requestLayout();
                parent.invalidate();
            }

            buttonAnimator.playTogether(ObjectAnimator.ofFloat(mDeleteButton, "translationX", 0, leftX),
                    ObjectAnimator.ofFloat(mDeleteButton, "translationY", 0, leftY),
                    ObjectAnimator.ofFloat(mSaveButton, "translationX", 0, rightX),
                    ObjectAnimator.ofFloat(mSaveButton, "translationY", 0, rightY),
                    ObjectAnimator.ofFloat(mPlayButton, "translationY", 0, buttonOffset));
            buttonAnimator.setInterpolator(new OvershootInterpolator());
            break;
        case -1: // in
            // keyView is the view we want to be at the front after an inward animation
            if (keyView != null) {
                keyView.bringToFront();
                if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
                    // need to manually re-layout before KitKat
                    parent = (RelativeLayout) keyView.getParent();
                    parent.requestLayout();
                    parent.invalidate();
                }
            }

            buttonAnimator.playTogether(ObjectAnimator.ofFloat(mDeleteButton, "translationX", leftX, 0),
                    ObjectAnimator.ofFloat(mDeleteButton, "translationY", leftY, 0),
                    ObjectAnimator.ofFloat(mSaveButton, "translationX", rightX, 0),
                    ObjectAnimator.ofFloat(mSaveButton, "translationY", rightY, 0),
                    ObjectAnimator.ofFloat(mPlayButton, "translationY", buttonOffset, 0));
            buttonAnimator.setInterpolator(new AnticipateInterpolator());
            break;
        }
        buttonAnimator.setDuration(BUTTON_ANIMATION_DURATION);
        buttonAnimator.start();
    }

    private void delayedResetRecordingInterface() {
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                resetRecordingInterface();
            }
        }, BUTTON_ANIMATION_DURATION * 2); // show briefly in final position, then hide
    }

    private void resetRecordingInterface() {
        if (mAudioRecorder != null) {
            if (mAudioRecorder.isRecording()) {
                mAudioRecorder.pause(null);
            }
        }
        mAudioRecorder = null; // TODO: delete audio file (if it exists)?

        if (mCurrentAudioRect != null) {
            mImageView.removeAudioAreaRect(mCurrentAudioRect);
        }
        mCurrentAudioRect = null;

        mRecordButton.setVisibility(View.INVISIBLE);
        mRecordButton.setImageResource(R.drawable.ic_mic_white_24dp);
        mPlayButton.clearAnimation();
        mPlayButton.setVisibility(View.INVISIBLE);
        mDeleteButton.setVisibility(View.INVISIBLE);
        mSaveButton.setVisibility(View.INVISIBLE);
        mSaveButton.setClickable(true);

        mImageView.setScribbleEnabled(true);
        getSupportActionBar().setTitle(getString(R.string.title_activity_add));
    }

    public void streamAudioLoadCompleted(String url) {
        streamAudio(url);
    }

    public void streamAudioLoadFailed(int reason) {
        mPlayButton.clearAnimation(); // instead of just cancelling the animation, as that means we can't hide the view
        mPlayButton.setImageResource(R.drawable.ic_play_arrow_white_24dp);
        resetAudioPlayer();
        Toast.makeText(PaperChainsActivity.this, getString(reason), Toast.LENGTH_SHORT).show();
    }

    private void streamAudio(String audioPath) {
        resetAudioPlayer();

        mPlayButton.setVisibility(View.VISIBLE); // undo invisible by resetAudioPlayer();
        mPlayButton.setImageResource(R.drawable.ic_refresh_white_24dp);
        mPlayButton.startAnimation(mRotateAnimation);

        mAudioPlayer = new MediaPlayer();
        mAudioPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);

        try {
            try {
                // slightly hacky way of detecting whether the path is a url or a file, and handling appropriately
                new URL(audioPath); // only for the exception it throws
                mAudioPlayer.setDataSource(audioPath);
            } catch (MalformedURLException e) {
                FileInputStream inputStream = new FileInputStream(audioPath);
                mAudioPlayer.setDataSource(inputStream.getFD());
                inputStream.close();
            }
            mAudioPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
                @Override
                public void onPrepared(MediaPlayer mp) {
                    mp.start();
                    mPlayButton.clearAnimation();
                    mPlayButton.setImageResource(R.drawable.ic_pause_white_24dp);
                }
            });
            mAudioPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
                @Override
                public void onCompletion(MediaPlayer mp) {
                    mPlayButton.clearAnimation();
                    mPlayButton.setImageResource(R.drawable.ic_play_arrow_white_24dp);
                }
            });
            mAudioPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
                @Override
                public boolean onError(MediaPlayer mp, int what, int extra) {
                    streamAudioLoadFailed(R.string.hint_soundcloud_load_failed);
                    return true;
                }
            });
            mAudioPlayer.prepareAsync();
        } catch (IOException e) {
            streamAudioLoadFailed(R.string.hint_soundcloud_load_failed);
        }
    }

    private void resetAudioPlayer() {
        mPlayButton.clearAnimation();
        mPlayButton.setVisibility(View.INVISIBLE);
        if (mAudioPlayer != null) {
            mAudioPlayer.release();
        }
        mAudioPlayer = null;
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, final Intent data) {
        switch (requestCode) {
        case SOUNDCLOUD_LOGIN_RESULT:
            if (resultCode == Activity.RESULT_OK && data != null) {
                String token = data.getStringExtra(SoundCloudLoginActivity.ACCESS_TOKEN_RESULT);
                if (!TextUtils.isEmpty(token)) {
                    setSoundCloudAccessToken(token);
                    saveAudio(); // try again now that we have a login token
                } else {
                    audioSaveFailed(R.string.soundcloud_login_failed);
                }
            } else {
                audioSaveFailed(R.string.soundcloud_login_failed);
            }
            break;

        default:
            super.onActivityResult(requestCode, resultCode, data);
            break;
        }
    }
}