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.
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.
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.
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:
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:
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!