Kean Walmsley


  • About the Author
    Kean on Google+

July 2014

Sun Mon Tue Wed Thu Fri Sat
    1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31    








« Creating a 3D viewer for our Apollonian service using Android – Part 1 | Main | Creating a 3D viewer for our Apollonian service using Android – Part 3 »

May 02, 2012

Creating a 3D viewer for our Apollonian service using Android – Part 2

In the last post, we took a quick look at some basics around developing application for Android, while today we’re going to see the code for our Apollonian Viewer application. Or, as my 5 year-old likes to call it, the “sweetie planet” app :-).

Last time, I mentioned Dennis Ippel as the author of the Rajawali framework I’ve used in this app. What I didn’t mention is how helpful he has been with getting this app working: Dennis gave hints that unblocked my efforts on a number of occasions, and even implemented new capabilities in Rajawali to enable several of the features I wanted to implement. Just from that perspective, developing this app has been a really rewarding experience – many thanks, Dennis! :-)

Before we see the code, I have a few things to say, from the outset. As this is my first Android app, I’m almost certainly doing some things the wrong way. I know, for instance, that I should really be using XML layouts to define my UI (in much the same way as you use XAML + C# code-behind in the .NET world), but for expediency – and to reduce the number of source files for you to copy/paste into your own project to get something working – I’m generating the various UI elements programmatically.

I’ve also cut a few corners when exposing properties from the ApollonianRenderer class to be used in the main ApollonianActivity class – again primarily for expediency (and a touch of laziness :-).

So while the app works – and right now I’m happy to see it’s working really well, in my somewhat biased opinion, at least – it should be looked at as a testament for how straightforward it is for a C# developer to write his first Android app rather than an example of Java/Android best-practices. And the point of all this is to demonstrate some potential benefits of splitting your application architecture with some code residing in the cloud and some working locally.

And with that, on to the source code…

The code is split across two source files: ApollonianActivity.java contains the main “application” class, if you will, and ApollonianRendered.java contains the code defining the OpenGL/Rajawali renderer class and its supporting functions.

I could certainly have broken out a number of features into separate classes, but, again for expediency, I’ve attempted to minimise the structural complexity perhaps at the cost of the overall architecture. I’ll probably choose to refactor if making significant extensions to this codebase, in the future.

I used the Java2Html Eclipse plug-in to generate the source for this blog post, having initially imposed the usual formatting style for my code (Eclipse is extremely flexible about imposing your own code formatting rules, I’ve found).

If you’re not especially interested in the code, please do skip past it to see screenshots and a video and to find a link to the app for those of you with Android devices.

Here’s the ApollonianActivity.java file:

package ttif.apollonian.app;
 
import java.util.ArrayList;
 
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.graphics.Color;
import android.graphics.PointF;
import android.os.Bundle;
import android.util.FloatMath;
import android.view.GestureDetector;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.ArrayAdapter;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.Spinner;
import android.widget.TextView;
 
import rajawali.RajawaliActivity;
 
public class ApollonianActivity
  extends RajawaliActivity implements View.OnClickListener
{
  private ApollonianRenderer mRenderer;
  private TextView mLabel;
  private ProgressBar mProgBarI;
  private ProgressBar mProgBarD;
  private Spinner mSpinner;
 
  private GestureDetector mGestureDetector;
  View.OnTouchListener mGestureListener;
 
  private static final int SWIPE_MIN_DISTANCE = 120;
  private static final int SWIPE_THRESHOLD_VELOCITY = 1000;
 
  // We can be in one of these 3 states
 
  private static final int NONE = 0;
  private static final int DRAG = 1;
  private static final int ZOOM = 2;
  private int mode = NONE;
 
  // Remember some things for zooming
 
  PointF start = new PointF();
  PointF mid = new PointF();
  float oldDist = 1f;   
 
  @Override
  public void onCreate(Bundle savedInstanceState)
  {
    super.onCreate(savedInstanceState);
 
    boolean restoring = savedInstanceState != null;
 
    // Set up our Rajawali renderer
 
    mRenderer = new ApollonianRenderer(this);
    mRenderer.setSurfaceView(mSurfaceView);
    super.setRenderer(mRenderer);
 
    // Set up the UI
   
    createUI();
   
    // Add touch and gesture detection
   
    mGestureDetector =
      new GestureDetector(new MyGestureDetector());
    mGestureListener = new View.OnTouchListener()
    {
      public boolean onTouch(View v, MotionEvent event)
      {
        // Handle touch events here...
     
        switch (event.getAction() & MotionEvent.ACTION_MASK)
        {
        case MotionEvent.ACTION_DOWN:
          start.set(event.getX(), event.getY());
          mode = DRAG;
          break;
        case MotionEvent.ACTION_POINTER_DOWN:
          oldDist = spacing(event);
          if (oldDist > 10f)
          {
            midPoint(mid, event);
            mode = ZOOM;
          }
          break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_POINTER_UP:
          mode = NONE;
          mRenderer.pinchOrDragFinished();
          break;
        case MotionEvent.ACTION_MOVE:
          if (mode == DRAG)
          {
            float x = event.getX() - start.x,
                  y = event.getY() - start.y;
            if (Math.abs(x) > 10f || Math.abs(y) > 10f)
              mRenderer.drag(x, y);
          }
          else if (mode == ZOOM)
          {
            float newDist = spacing(event);
            if (newDist > 10f)
            {
               float scale = newDist / oldDist;
               mRenderer.pinch(scale);
            }
          }
          break;
        }       
        // Make sure we call our gesture detector, too
 
        return mGestureDetector.onTouchEvent(event);
      }
    };
    
    // Hook up our touch-related listeners
   
    mSurfaceView.setOnClickListener(this);
    mSurfaceView.setOnTouchListener(mGestureListener);
    mLabel.setOnClickListener(this);
    mLabel.setOnTouchListener(mGestureListener);
 
    // If we have saved state, pass it to the renderer
   
    if (restoring)
    {
      mRenderer.mBundle = savedInstanceState;
      mRenderer.restoreInstancedState();
    }
  }

  private void createUI()
  {
    // Start with the overall vertical layout
   
    LinearLayout ll = new LinearLayout(this);
    ll.setOrientation(LinearLayout.VERTICAL);
    ll.setGravity(
      Gravity.CENTER_HORIZONTAL + Gravity.CENTER_VERTICAL
    );

    // Have an information label centered on the screen
   
    mLabel = new TextView(this);
    mLabel.setGravity(Gravity.CENTER_HORIZONTAL);
    mLabel.setTextSize(40);
    mLabel.setPadding(0, 0, 0, 30);
    ll.addView(mLabel);

    // We'll have two progress bars, one is indeterminate...
   
    mProgBarI =
      new ProgressBar(
        this, null, android.R.attr.progressBarStyleLarge
      );
    mProgBarI.setIndeterminate(true);
    mProgBarI.setVisibility(View.GONE);
    ll.addView(mProgBarI, 50, 50);
   
    // ... the other is determinate
   
    mProgBarD =
      new ProgressBar(
        this, null, android.R.attr.progressBarStyleHorizontal
      );
    mProgBarD.setIndeterminate(false);
    mProgBarD.setVisibility(View.GONE);
    mProgBarD.setPadding(50, 0, 50, 0);
    mProgBarD.setProgress(0);
    ll.addView(mProgBarD);

    mLayout.addView(ll);
   
    // To place our spinner at the bottom right corner,
    // we first add a horizontal layout
   
    LinearLayout bottom = new LinearLayout(this);
    bottom.setOrientation(LinearLayout.HORIZONTAL);   
    bottom.setGravity(Gravity.BOTTOM);
    bottom.setLayoutParams(
      new LinearLayout.LayoutParams(
        LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT
      )
    );
   
    // And then a vertical one
   
    LinearLayout right = new LinearLayout(this);
    right.setOrientation(LinearLayout.VERTICAL);
    right.setGravity(Gravity.RIGHT);
    right.setLayoutParams(
      new LinearLayout.LayoutParams(LayoutParams.FILL_PARENT, 100)
    );
   
    bottom.addView(right);

    // We create the data for our spinner
   
    ArrayList<String> spinnerArray = new ArrayList<String>();
    for (int i = 1; i < 11; i++)
    {
      spinnerArray.add(
        String.format(
          "Level %-15s(%,d spheres)",
          Integer.toString(i), ApollonianRenderer.mLimits[i]
        )
      );
    };

    // And then create the spinner itself
   
    mSpinner = new Spinner(ApollonianActivity.this);
    ArrayAdapter<String> spinnerArrayAdapter =
      new ArrayAdapter<String>(
        ApollonianActivity.this,
        android.R.layout.simple_dropdown_item_1line,
        spinnerArray
      )
      {
        // We want to set the color of our spinner's TextView
        // to something other than black
       
        @Override
        public View getView(
          final int position, View convertView, ViewGroup parent
        )
        {         
          View v = super.getView(position, convertView, parent);
          TextView tv = (TextView)v;
          tv.setTextColor(Color.WHITE);
          return v;
        }
      };
    mSpinner.setAdapter(spinnerArrayAdapter);
    mSpinner.setSelection(mRenderer.mLevel - 1);
    mSpinner.setLayoutParams(
      new LinearLayout.LayoutParams(180, 100, Gravity.RIGHT)
    );
    mSpinner.setBackgroundColor(Color.TRANSPARENT);
    mSpinner.setVisibility(View.GONE);
   
    mSpinner.setOnItemSelectedListener(
      new OnItemSelectedListener()
      {
        public void onItemSelected(
          AdapterView<?> arg0, View arg1, int arg2, long arg3
        )
        {
          // When a selection happens, inform the renderer
         
          if (arg2 != mRenderer.mLevel - 1)
          {
            mRenderer.mLevel = arg2 + 1;
            mRenderer.startDownload();
          }
        }

        public void onNothingSelected(AdapterView<?> arg0)
        {
          // If no selection happens make sure we're still
          // selecting the previous value
         
          arg0.setSelection(mRenderer.mLevel - 1);
        }
      }
    );
    right.addView(mSpinner);
   
    mLayout.addView(bottom);
  }
 
  // State save/restore methods
 
  @Override
  public void onSaveInstanceState(Bundle savedInstanceState)
  {
    // Save state changes to the savedInstanceState.

    mRenderer.saveInstancedState(savedInstanceState);

    super.onSaveInstanceState(savedInstanceState);
  }

  // Determine the space between the first two fingers
 
  private float spacing(MotionEvent event)
  {
    return
      distance(
        event.getX(0), event.getX(1),
        event.getY(0), event.getY(1)
      );
  }
 
  // Determine the distance between two points
 
  private float distance(float x1, float x2, float y1, float y2)
  {
    float x = x1 - x2, y = y1 - y2;
    return FloatMath.sqrt(x * x + y * y);
  }

  // Calculate the mid point of the first two fingers
 
  private void midPoint(PointF point, MotionEvent event)
  {
    float x = event.getX(0) + event.getX(1);
    float y = event.getY(0) + event.getY(1);
    point.set(x / 2, y / 2);
  }
 
  // We need an empty onClick() for touch to work properly
 
  public void onClick(View unused)
  {
  }

  // Helper functions providing access to the information label
 
  public void clearLabel()
  {
    mLabel.setText("");
    mProgBarI.setVisibility(View.GONE);
    mProgBarD.setVisibility(View.GONE);
    mLabel.setVisibility(View.GONE);
    mSpinner.setVisibility(View.VISIBLE);
  }

  public void setLabel(String text)
  {
    mLabel.setVisibility(View.VISIBLE);
    mLabel.setText(text);
    mSpinner.setVisibility(View.GONE);
  }

  // Progress bar related methods
 
  public void showProgressBar(boolean determinate)
  {
    int det = determinate ? View.VISIBLE : View.GONE,
        ind = determinate ? View.GONE : View.VISIBLE;

    mProgBarI.setVisibility(ind);
    mProgBarD.setVisibility(det);
  }

  public void setProgressBarLimit(int i)
  {
    mProgBarD.setMax(i);
  }

  public void tickProgressBar()
  {
    mProgBarD.incrementProgressBy(1);
  }
 
  public void setProgressBarPosition(int i)
  {
    mProgBarD.setProgress(i);
  }

  // Spinner-related methods

  public void setLevel(Integer level)
  {
    mSpinner.setSelection(level - 1);
  }

  // Stop the activity in case we have no network connection
 
  public void exitNoConnection()
  {
    AlertDialog ad =
      new AlertDialog.Builder(ApollonianActivity.this).create();
    ad.setCancelable(false);
    ad.setTitle(
      "Unable to access the Apollonian Service."
    );
    ad.setMessage(
      "Please make sure you have internet connectivity."
    );
    ad.setButton(
      "Close",
      new DialogInterface.OnClickListener()
      {
        public void onClick(DialogInterface dialog, int which)
        {
          dialog.dismiss();
          ApollonianActivity.this.finish();
        }
      }
    );
    ad.show();
  }
 
  // Our gesture detector class
 
  class MyGestureDetector extends SimpleOnGestureListener
  {
    @Override
    public boolean onFling(
      MotionEvent e1, MotionEvent e2,
      float velocityX, float velocityY
    )
    {
      try
      {
        float x1 = e1.getX(), y1 = e1.getY(),
              x2 = e2.getX(), y2 = e2.getY();
         
        if (distance(x1, x2, y1, y2) > SWIPE_MIN_DISTANCE &&
          Math.abs(velocityX) + Math.abs(velocityY) >
            SWIPE_THRESHOLD_VELOCITY)
        {
          mRenderer.swipe(x2 - x1, y1 - y2);
          return true;
        }
      }
      catch (Exception e)
      {
      }
      return false;
    }
   
    @Override
    public boolean onSingleTapConfirmed(MotionEvent e)
    {
      mRenderer.singleTap();
      return true;
    }

    @Override
    public boolean onDoubleTapEvent(MotionEvent e)
    {
      mRenderer.doubleTap();
      return true;
    }
  }
}

And here’s the ApollonianRenderer.java file:

package ttif.apollonian.app;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import android.graphics.Color;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.view.animation.LinearInterpolator;

import rajawali.BaseObject3D;
import rajawali.animation.Animation3D;
import rajawali.animation.RotateAnimation3D;
import rajawali.lights.DirectionalLight;
import rajawali.materials.DiffuseMaterial;
import rajawali.math.Matrix4;
import rajawali.math.Number3D;
import rajawali.math.Quaternion;
import rajawali.primitives.Sphere;
import rajawali.renderer.RajawaliRenderer;
import rajawali.util.RajLog;

public class ApollonianRenderer extends RajawaliRenderer
{
  private BaseObject3D mRootSphere = null;
  private DirectionalLight mLight = null;
  private Animation3D mAnim = null
  private Context mContext;

  private static final int baseDuration = 4000;
  private static final int minDuration = 1000;
   
  private int mAnimDuration = baseDuration;
  private Number3D mDirection = new Number3D();
  private boolean mAnimPaused = false;
  private float mCameraDistance = -5f;
  private float mRecentCameraDistance;
  private Quaternion mDragRotation = null;

  private String mJsonResults;

  // Colors per level
 
  private static final int[] mColors =
    new int[]
    {
      Color.TRANSPARENT, Color.RED, Color.YELLOW, Color.GREEN,
      Color.CYAN, Color.BLUE, Color.MAGENTA, Color.DKGRAY,
      Color.GRAY, Color.LTGRAY, Color.WHITE
    };

  // Numbers of spheres to be downloaded per level
 
  public static final int[] mLimits =
    new int[]
    {
      1, 9, 29, 89, 299, 989, 2837, 6635, 12119, 16187, 18107
    };

  // Public bundle for restoring data
 
  public Bundle mBundle = null;
 
  // The level we'll download and process
 
  public int mLevel = 3;
 
  // Initialization/set-up related
 
  public ApollonianRenderer(Context context)
  {
    super(context);
    mContext = context;
    setFrameRate(40);
  }
 
  protected void initScene()
  {
    // We'll restart rendering once we've downloaded/processed
   
    stopRendering();
   
    // Set up the camera
   
    mCamera.setPosition(0f,0f,0f);
    setCameraDistance(1f);

    // Create the default light
   
    createLight();
   
    // If we have geometry, exit now
   
    if (mJsonResults != null)
    {
      refreshGeometry();
      return;
    }
   
    // Otherwise we download and process it
   
    startDownload();
  }

  private void createLight()
  {
    // Add a light source

    if (mLight == null)
    {
      mLight = new DirectionalLight(0.1f, 0.2f, -1.0f);
      mLight.setColor(1.0f, 1.0f, 1.0f);
      mLight.setPosition(.5f, 0, -2);
      mLight.setPower(0.5f);
    }
  }

  private void storeRotation()
  {
    mDragRotation = mRootSphere.getOrientation();
  }
 
  private void setCameraDistance(float scale)
  {
    // Set our camera distance
   
    mRecentCameraDistance = mCameraDistance / scale;
    mCamera.setZ(mRecentCameraDistance);   
  }

  private void cancelAnimation()
  {
    if (mAnim != null)
    {
      mAnim.cancel();
      mAnim = null;
    }
  }

  private void resetAnimation(boolean sameOrOpposite)
  {
    // Start by canceling any existing animation
   
    cancelAnimation();
   
    // Get our axis of rotation, perpendicular to the swipe
    // direction
   
    Number3D axis = perpendicularAxis(-mDirection.x, mDirection.y);
    axis.normalize();

    Quaternion q = mRootSphere.getOrientation();
    Matrix4 mat = q.toRotationMatrix().inverse();
    axis = mat.transform(axis);
    axis.normalize();

    mAnim = new RotateAnimation3D(axis, 360);
    mAnim.setDuration(mAnimDuration);
    mAnim.setTransformable3D(mRootSphere);
    mAnim.setRepeatCount(Animation3D.INFINITE);
    mAnim.setRepeatMode(Animation3D.RESTART);
    mAnim.setInterpolator(new LinearInterpolator());
    mAnim.start();
  }

  // Touch gesture protocol

  public void singleTap()
  {
    // Pauses or restarts spinning
   
    if (mAnim != null)
    {
      if (mAnimPaused)
        mAnim.start();
      else
      {
        mAnim.cancel();
        storeRotation();
      }
      mAnimPaused = !mAnimPaused;
    }
  }

  public void doubleTap()
  {
    // Cancels spinning
   
    cancelAnimation();
   
    mAnimPaused = false;
    mAnimDuration = baseDuration;
   
    storeRotation();   
  }
 
  public void drag(float x, float y)
  {
    // Rotates a short distance
   
    if (mAnim == null || mAnimPaused)
    {
      // Determine how far to rotate and in which direction
     
      float rotAng = magnitudeOfRotation(x, y) * -0.1f;
      Number3D axis = perpendicularAxis(x, y);

      // If our objects have an existing rotation, transform
      // the axis of rotation
     
      if (mDragRotation != null)
      {
        Matrix4 mat = mDragRotation.toRotationMatrix().inverse();
        axis = mat.transform(axis);
      }
      axis.normalize();     

      // Get the new rotation as a quaternion
     
      Quaternion rot = new Quaternion();
      rot.fromAngleAxis(rotAng, axis);

      // Apply any existing rotation to it
     
      if (mDragRotation != null)
        rot.multiply(mDragRotation);

      mRootSphere.setOrientation(rot);
    }
  }

  public void swipe(float x, float y)
  {
    // Spins in a particular direction
   
    mAnimPaused = false;
   
    boolean needReset = false, sameOrOpposite = false;
    if (mAnim == null)
    {
      // No existing animation
     
      mDirection = new Number3D(x, y, 0f);
      needReset = true;
    }
    else
    {
      // Existing animation...
     
      if (sameDirection(x, y, mDirection.x, mDirection.y))
      {
        sameOrOpposite = true;
        // ... in the same direction as the swipe, so we
        // speed up the animation by halving the duration
       
        if ((mAnimDuration / 2) >= minDuration)
        {
          mAnimDuration /= 2;
          needReset = true;
        }
      }
      else
      {
        // A new direction, reset the duration
       
        mDirection = new Number3D(x, y, 0f);
        mAnimDuration = baseDuration;
        needReset = true;
        sameOrOpposite =
          sameDirection(-x, -y, mDirection.x, mDirection.y);
      }
    }
    if (needReset)
      resetAnimation(sameOrOpposite);
  }
 
  public void pinch(float scale)
  {
    // Zooms the view
   
    setCameraDistance(scale);
  }
 
  public void pinchOrDragFinished()
  {
    mCameraDistance = mRecentCameraDistance;

    storeRotation();
  }
 
  // Touch-related helpers
 
  private boolean sameDirection(
    float x1, float y1, float x2, float y2
  )
  {
    return Math.abs(Math.atan2(y1,x1) - Math.atan2(y2,x2)) < 0.1;
  }

  private Number3D perpendicularAxis(float x, float y)
  {
    // Uses a fairly unsophisticated approach to generating
    // a perpendicular vector
   
    if (y == 0)
      return new Number3D(y, -x, 0);
    else
      return new Number3D(-y, x, 0);
  }

  private float magnitudeOfRotation(float x, float y)
  {
    return new Number3D(x, y, 0).length();
  }

  // Web-service access
 
  private static String convertStreamToString(InputStream is)
  {
    BufferedReader reader =
      new BufferedReader(new InputStreamReader(is));
    StringBuilder sb = new StringBuilder();

    String line = null;
    try
    {
      while ((line = reader.readLine()) != null)
      {
        sb.append(line + "\n");
      }
    }
    catch (IOException e)
    {
      e.printStackTrace();
    }
    finally
    {
      try
      {
        is.close();
      }
      catch (IOException e)
      {
        e.printStackTrace();
      }
    }

    return sb.toString();
  }

  // Get the spheres for a certain level from our web-service
 
  private static String getSpheresFromService(int level)
    throws ClientProtocolException, IOException
  {
    HttpClient httpclient = new DefaultHttpClient();

    // Prepare and execute the request
   
    HttpGet httpget =
      new HttpGet(
        "http://apollonian.cloudapp.net/api/spheres/1/" +
          Integer.toString(level)
      );
    HttpResponse response;
    //try
    {
      response = httpclient.execute(httpget);
      HttpEntity entity = response.getEntity();
      if (entity != null)
      {
        InputStream instream = entity.getContent();
        String result = convertStreamToString(instream);       
        instream.close();

        return result;
      }
    }
    return null;
  }

  // Geometry creation
 
  private void createSpheres()
  {
    createSpheres(mJsonResults);
  }
 
  private void createSpheres(String results)
  {
    if (results == null || results == "")
      return;

    setProgressLimit(mLimits[mLevel]);
   
    // Add our primary (dummy) sphere
   
    createLight();
    createSphere(0, 0, 0, 0.01f, 0, true);

    try
    {
      // Our JSON string should be an array of objects
     
      JSONArray json = new JSONArray(results);       
      for (int i = 0; i < json.length(); i++)
      {
        float x = 0f, y = 0f, z = 0f, radius = 0f;
        int level = 0;
       
        // Get each object from the array
       
        JSONObject obj = json.getJSONObject(i);
     
        // Parse the name-value pairs to get our data
       
        JSONArray nameArray = obj.names();
        JSONArray valArray = obj.toJSONArray(nameArray);
        for (int j = 0; j < valArray.length(); j++)
        {
          // Cannot switch on a string, so we cascade ifs
         
          String name = nameArray.get(j).toString();
          if (name.startsWith("X"))
            x = Float.valueOf(valArray.get(j).toString());
          else if (name.startsWith("Y"))
            y = Float.valueOf(valArray.get(j).toString());
          else if (name.startsWith("Z"))
            z = Float.valueOf(valArray.get(j).toString());
          else if (name.startsWith("R"))
            radius =
              Float.valueOf(valArray.get(j).toString());
          else if (name.startsWith("L"))
            level = (Integer)valArray.get(j);
        }
       
        // Only display circles that are close to the outer
        // hull (and are therefore not completely occluded)
       
        Number3D v = new Number3D(x, y, z);
        if (v.length() + radius > 0.99f)
          createSphere(x, y, z, radius, level, false);

        tickProgress();
      }
    }
    catch (JSONException e)
    {
      e.printStackTrace();
    }
  }

  private void clearSpheres()
  {
    if (mRootSphere != null)
    {
      removeChild(mRootSphere);
      mRootSphere = null;
    }
  };
 
  private void createSphere(
    float x, float y, float z, float radius,
    int level, boolean isRoot
  )
  {
    if (isRoot && mRootSphere != null)
      return;
   
    // Use a moderate tessellation of our spheres
   
    Sphere sphere = new Sphere(radius, 9, 9);
    sphere.setX(x);
    sphere.setY(y);
    sphere.setZ(z);
   
    // Add our light
   
    sphere.addLight(mLight);
   
    // Set the material and color
   
    sphere.setMaterial(new DiffuseMaterial());
    sphere.getMaterial().setUseColor(true);
    sphere.setColor(level > 10 ? Color.WHITE : mColors[level]);
   
    // Make it our root sphere or add it as a child
   
    if (isRoot)
    {
      mRootSphere = sphere;
      addChild(mRootSphere);
    }
    else if (mRootSphere != null)
    {     
      mRootSphere.addChild(sphere);
    }
  }

  // We have some things we need to have happen on the UI thread...
 
  // Start downloading data from our web-service
 
  public void startDownload()
  {
    clearSpheres();
   
    mHandler.post(
      new Runnable()
      {
        public void run()
        {
          new DownloadSpheresTask().execute();
        }
      }
    );
  }
 
  // Clear the information label once we've finished
 
  private void clearProgressLabel()
  {
    Message msg = new Message();
    msg.arg1 = 0;
    mHandler.sendMessage(msg);
  }

  // Update the information label as we're downloading/processing
 
  private void updateProgressLabel(String text, boolean determinate)
  {
    Message msg = new Message();
    msg.arg1 = determinate ? 1 : 2;
    msg.obj = text;
    mHandler.sendMessage(msg);
  }

  // Control the determinate progress bar
 
  private void setProgressLimit(int max)
  {
    Message msg = new Message();
    msg.arg1 = 3;
    msg.obj = max;
    mHandler.sendMessage(msg);
  }

  private void tickProgress()
  {
    Message msg = new Message();
    msg.arg1 = 4;
    mHandler.sendMessage(msg);
  }

  private void resetProgress()
  {
    Message msg = new Message();
    msg.arg1 = 5;
    mHandler.sendMessage(msg);
  }

  private void setSpinnerLevel(int level)
  {
    Message msg = new Message();
    msg.arg1 = 6;
    msg.obj = level;
    mHandler.sendMessage(msg);
  }

  private void exitGracefully()
  {
    Message msg = new Message();
    msg.arg1 = 7;
    mHandler.sendMessage(msg);
  }

  // Here's the handler that manages the UI updates
 
  private Handler mHandler = new Handler()
  {
    @Override
    public void handleMessage(Message msg)
    {     
      ApollonianActivity act = (ApollonianActivity)mContext;
      switch (msg.arg1)
      {
      case 0:
        act.clearLabel();
        break;
      case 1:
        act.setLabel((String)msg.obj);
        act.showProgressBar(true);
        break;
      case 2:
        act.setLabel((String)msg.obj);
        act.showProgressBar(false);
        break;
      case 3:
        act.setProgressBarLimit((Integer)msg.obj);
        break;
      case 4:
        act.tickProgressBar();
        break;
      case 5:
        act.setProgressBarPosition(0);
        break;
      case 6:
        act.setLevel((Integer)msg.obj);
        break;
      case 7:
        act.exitNoConnection();
        break;
      }
    }
  };
 
  // Background task allowing us to download without
  // blocking the UI thread
 
  private class DownloadSpheresTask
    extends AsyncTask<Void, Void, Void>
  {
    @Override
    protected Void doInBackground(Void... unused)
    {
      updateProgressLabel("Calling Apollonian Service...", false);

      try
      {
        mJsonResults = getSpheresFromService(mLevel);

        refreshGeometry();       
      }
      catch (ClientProtocolException e)
      {
        e.printStackTrace();
      }
      catch (IOException e)
      {
        exitGracefully();
      }
      return null;
    }
  }

  // Refresh our geometry, whether downloaded or restored
 
  private void refreshGeometry()
  {
    resetProgress();
   
    updateProgressLabel("Creating Spheres...", true);

    // Call on the rendering thread...
   
    mSurfaceView.queueEvent(
      new Runnable()
      {
        public void run()
        {
          RajLog.enableDebug(false);
          createSpheres();
          RajLog.enableDebug(true);

          clearProgressLabel();
         
          startRendering();
        }
      }
    );
  }

  // Save our JSON data for when we restore (this happens when
  // we change tablet orientation, for instance)
 
  public void saveInstancedState(Bundle savedInstanceState)
  {
    savedInstanceState.putString("Json", mJsonResults);
    savedInstanceState.putInt("Level", mLevel);
  }

  // Restore our state by re-processing the JSON
 
  public void restoreInstancedState()
  {
    restoreInstancedState(mBundle);
  }

  public void restoreInstancedState(Bundle savedInstanceState)
  {
    if (savedInstanceState != null)
    {
      mJsonResults = savedInstanceState.getString("Json");
      mLevel = savedInstanceState.getInt("Level");
 
      setSpinnerLevel(mLevel);
    }
  }
}

Aside from these source files, there are minor changes needed to the standard project set-up: firstly, I’ve customised the default launcher icons in the appropriate resource locations. Secondly, we also need to flag our app as requiring Internet access, which can be done by adding this line to the AndroidManifest.xml file:

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

Before we see the app in action, a few words on my experience developing this app.

One thing I found, pretty quickly, is that you need to be aware of which thread is executing which operation. My initial version of the app accessed the web-service from the UI thread, which meant the screen didn’t refresh, at all. Which was OK until I wanted to integrate a process bar, or keep the UI responsive in case the device was rotated.

So I used an AsyncTask to move the web-service call and processing away from the main UI thread. But then the OpenGL/Rajawali geometry needed to be added on the rendering thread, which I ultimately managed to have happen via the mSurfaceView member of the Activity class. Until I found that little trick, no geometry appeared at all, which was rather frustrating.

And all the UI updates need to be made back on the main thread, of course, so that took a little more work to make happen.

Thankfully Eclipse makes it easy to keep track of which thread is executing as you step through code in the debugger. Now I know what to look for, I suspect it’ll be easier to develop further Android apps, in future.

I mentioned the need to respond to a device rotation… when that happens the Activity gets closed and recreated. But before that happens, the Activity has the chance to store some state – from onSaveInstanceState() – to be retrieved in the Activity’s onCreate() call. I decided to take the simple way out, here, and store the JSON string received from the web-service, to then reparse it and create our geometry. This was certainly simple to implement, but can be a bit time-consuming. There may be a better way to store and recreate our Rajawali geometry, sphere-by-sphere, but for now I’ve kept things simple.

Now for some screenshots of the app working on my Kindle Fire…

Firstly, here’s the app’s icon in the “carrousel” view and in the main app list. Apparently it’s only possible to display the higher-resolution icons (512 x 512) in apps that have been installed from the Amazon Store, which is a bit annoying but understandable.

Our app on the Kindle carrouselOur apps

When the app is launched you get to see two progress bars in action – the first is an indeterminate one (with Kindle Fire branding being picked up automagically) for the call to the web-service (as we don’t know how long it will take) and the second is a determinate one, as we know how many spheres we will be creating for that level.

First progress bar during web-service callSecond progress bar while creating spheres

By default the “Level 3” packing is displayed. This can be changed by touching the “Level 3” label, which brings up a list of possible levels to select from.

Level 3Selecting a new level

Be warned: I’ve left all the various levels available from the web-service on the list. On my Kindle Fire, I can only load up to level 5 before the app dies (presumably from lack of memory). Here are the various levels on my Kindle Fire, as previewed in the last post:

Level 1Level 2Level 3Level 4Level 5

On a friend’s Galaxy Tab 10.1 (thanks, Jonathan! :-), the app gets up to level 7, but won’t go beyond. Here are some screenshots from that device, to give you a feel for some of the differences:

Indeterminate progress on a Galaxy TabDeterminate progress on a Galaxy TabLevel 3 on a Galaxy Tab

Choosing a level on a Galaxy TabLevel 7 on a Galaxy Tab

Please be sure to post a comment if you manage to get beyond level 7 on your phone/tablet! :-)

Now for a brief description of the gestures you can use with the Apollonian model:

  • Drag-rotate – touching a location on the screen with your finger and then moving it gradually around, you’ll see the the model rotate along with your gesture.
  • Swipe-spin – performing a swipe will cause the sphere to start spinning in the direction of the swipe. Swiping again in the same direction will cause the rate of spin to increase.
  • Tap-pause/play – tapping the screen once will cause any spinning to pause. Tapping again will cause the spin to continue.
  • Double tap-stop – double-tapping the screen will cancel any ongoing spin.
  • Pinch-zoom – the classic pinch gesture will zoom in or out (even into to the model itself, if you want to check out its internals) .

Speaking of the model’s internals… just as with the recent Unity3D implementation, the above code filters out any spheres not near the outer edge of our packing. At some point there are a few settings I’d like to add, probably via the standard settings mechanism for Android apps: I’d like the option to toggle the addition of occluded spheres to the model, plus some kind of control over the smoothness of our spheres (right now I’ve hard-coded a segmentation of 9 for each of the width and height of the sphere: adjusting this would impact the polygon count and presumably the level a particular device could work with).

A quick word on the supporting tools I used to document the app in action…

The above Kindle Fire screenshots were taken with the standard DDMS (Dalvik Debug Monitor Service) tool that ships with the Android SDK. The Galaxy Tab has a screenshot capability integrated – something that could well be a standard Android feature, for all I know – which looked pretty handy. And apparently another tool called Droid@Screen can streamline this by placing a sequence of images in a particular folder, should you want to try it.

I found another tool called androidscreencast, which allowed me to record this simple demo video (at a pretty low frame-rate: all these tools use the mechanism provided by DDMS, it seems, and are therefore limited by the number of frames per second that can be transferred via the USB link). There are other tools out there that probably record at a higher frame-rate but require root access to your device, and I’ve chosen not to root my Kindle Fire, for now.

If you’re interested in side-loading the app to see it in action on your own Android device, here’s the .apk distribution. And please do let me know if you have any feedback!

blog comments powered by Disqus

10 Random Posts