I'm building a simple sketch program using JavaFX. I want the user to be able to switch between drawing a rectangle, circle or line and I've put toggle radio buttons in the menu for these options.
Is it possible to write an if/else statement so I can write code for three different functions depending on which is selected? At the moment it will only draw lines. This is part of my code so far (sorry it's messy):
package Sketchbook;
public class Sketchbook extends Application {
final static int CANVAS_WIDTH = 800;
final static int CANVAS_HEIGHT = 600;
ColorPicker colorPicker1;
ColorPicker colorPicker2;
#Override
public void start(final Stage primaryStage) {
final Canvas canvas = new Canvas(CANVAS_WIDTH, CANVAS_HEIGHT);
final GraphicsContext graphicsContext = canvas.getGraphicsContext2D();
initDraw(graphicsContext);
canvas.addEventHandler(MouseEvent.MOUSE_PRESSED,
new EventHandler<MouseEvent>(){
#Override
public void handle(MouseEvent event) {
graphicsContext.beginPath();
graphicsContext.moveTo(event.getX(), event.getY());
graphicsContext.setStroke(colorPicker1.getValue());
graphicsContext.stroke();
}
});
canvas.addEventHandler(MouseEvent.MOUSE_DRAGGED,
new EventHandler<MouseEvent>(){
#Override
public void handle(MouseEvent event) {
graphicsContext.lineTo(event.getX(), event.getY());
graphicsContext.setStroke(colorPicker1.getValue());
graphicsContext.stroke();
}
});
canvas.addEventHandler(MouseEvent.MOUSE_RELEASED,
new EventHandler<MouseEvent>(){
#Override
public void handle(MouseEvent event) {
}
});
Group root = new Group();
ToggleGroup toggleGroup = new ToggleGroup();
RadioButton rectangle = new RadioButton("Rectangle");
RadioButton circle = new RadioButton("Circle");
RadioButton line = new RadioButton("Line");
rectangle.setSelected(true);
rectangle.setToggleGroup(toggleGroup);
circle.setToggleGroup(toggleGroup);
line.setToggleGroup(toggleGroup);
You've pretty much already described what you need to do:
canvas.addEventHandler(MouseEvent.MOUSE_DRAGGED,
new EventHandler<MouseEvent>(){
#Override
public void handle(MouseEvent event) {
if (toggleGroup.getSelectedToggle() == line) {
graphicsContext.lineTo(event.getX(), event.getY());
graphicsContext.setStroke(colorPicker1.getValue());
graphicsContext.stroke();
} else if (toggleGroup.getSelectedToggle() == rectangle) {
// etc...
} // etc...
}
}
});
Obviously you may need to reorder the code a little to make sure variables are declared and initialized before you use them.
Related
I'm testing flutter FFI by getting colour data from C/C++ side and painting the screen using CustomPainter.
But to my surprise, even though I set the painter to always repaint, the paint() function is only called twice.
Code
class _ColorViewPainter extends CustomPainter {
Color clrBackground;
_ColorViewPainter({
this.clrBackground = Colors.black
});
#override
bool shouldRepaint(_ColorViewPainter old) => true;
#override
void paint(Canvas canvas, Size size) {
print("paint: start");
final color = ffiGetColor().ref;
final r = color.r;
final g = color.g;
final b = color.b;
print("color: $r, $g, $b");
final paint = Paint()
..strokeJoin = StrokeJoin.round
..strokeWidth = 1.0
..color = Color.fromARGB(255, r, g, b)
..style = PaintingStyle.fill;
final width = size.width;
final height = size.height;
final content = Offset(0.0, 0.0) & Size(width, height);
canvas.drawRect(content, paint);
print("paint: end");
}
}
The ffiGetColor() function simply retrieves a colour RGB struct from C/C++ side. I can see the screen being updated twice and the log says:
I/flutter (12096): paint: start
I/flutter (12096): color: 255, 0, 0
I/flutter (12096): paint: end
I/flutter (12096): paint: start
I/flutter (12096): color: 0, 255, 0
I/flutter (12096): paint: end
I/Surface (12096): opservice is null false
But that's it. Even though I clearly want it to repaint with shouldRepaint. flutter failed to do so.
What's wrong?
here is my environment
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel dev, v1.18.0-dev.5.0, on Mac OS X 10.15.5 19F101, locale en-CA)
[✓] Android toolchain - develop for Android devices (Android SDK version 30.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 11.5)
[✓] Android Studio (version 4.0)
[✗] Cannot determine if IntelliJ is installed
✗ Directory listing failed
[✓] VS Code (version 1.44.2)
[✓] Connected device (1 available)
! Doctor found issues in 1 category.
Thanks to #pskink 's tips, I managed to make my CustomPainter repaint continuously, although it's still not perfect.
See dart/flutter: CustomPaint updates at a lower rate than ValueNotifier's value update
Because there is still an update rate problem with the current solution, I'm just reporting what I've got briefly:
I basically have to construct a notifier and assign ffi-retrieved values to it at a proper location via polling.
// in initState() of State class
_notifier = ValueNotifier<NativeColor>(ffiGetColor().ref);
...
// in a timer method
_notifier.value = ffiGetColor().ref;
Then with CustomPaint, bind notifier to repaint in its constructor
#override
Widget build(BuildContext context) {
return Container(
...
child: CustomPaint(
painter: _ColorViewPainter(
context: context,
notifier: _notifier,
...
)
)
);
}
class _ColorViewPainter extends CustomPainter {
ValueNotifier<NativeColor> notifier;
BuildContext context;
Color clrBackground;
_ColorViewPainter({this.context, this.notifier, this.clrBackground})
: super(repaint: notifier) {
}
#override
bool shouldRepaint(_ColorViewPainter old) {
print('should repaint');
return true;
}
#override
void paint(Canvas canvas, Size size) {
print("paint: start");
final r = notifier.value.r;
final g = notifier.value.g;
final b = notifier.value.b;
print("color: $r, $g, $b");
final paint = Paint()
..strokeJoin = StrokeJoin.round
..strokeWidth = 1.0
..color = Color.fromARGB(255, r, g, b)
..style = PaintingStyle.fill;
final width = size.width;
final height = size.height;
final content = Offset(0.0, 0.0) & Size(width, height);
canvas.drawRect(content, paint);
print("paint: end");
}
}
GOTCHAS
ValueNotifier relies on the following conditions to be able to fire up notification to its listeners
The value type it binds to has defined the operator ==
The ValueNotifier's value field, which your data object of choice binds to, gets explicitly reassigned with a NEW data object on changes.
This is crucial when we bind the notifier to a custom class. With a custom class, we must
overload its operator ==.
ASSIGN a NEW object to ValueNotifier<MyClass>.value instead of modifying the data object's value by calling its own regular methods.
Otherwise the CustomPaint's paint() will not be called on desired changes.
To give an example, this custom class is not qualified to bind with a ValueNotifier, because there is no overloaded operator==:
class MyClass {
int prop = 0;
void changeValue(newValue) {
prop = newValue;
}
}
To give another example, assume we have a custom class:
class MyClass {
int prop = 0;
#override
bool operator ==(covariant MyClass other) {
return other is MyClass && prop != other. prop;
}
void changeValue(newValue) {
prop = newValue;
}
}
This will work:
class _MyViewState extends State<MyView> {
ValueNotifier<MyClass> notifier;
Timer _timer;
....
#override
initState() {
super.initState();
notifier = ValueNotifier<MyClass>(MyClass());
_timer = Timer.periodic(Duration(milliseconds: 10), _updateData);
}
_updateData(Timer t) {
var myObj = MyClass(newValue);
notifier.value = myObj;
}
}
But this won't work.
class _MyViewState extends State<MyView> {
ValueNotifier<MyClass> notifier;
Timer _timer;
....
#override
initState() {
super.initState();
notifier = ValueNotifier<MyClass>(MyClass());
_timer = Timer.periodic(Duration(milliseconds: 10), _updateData);
}
_updateData(Timer t) {
var newValue = getNewValueSomewhere();
notifier.value.changeValue(newValue);
}
In my map, I am trying to capture zoom in/out using ScaleGestureDetector but I am never receiving any callbacks to either of onScale or onScaleBegin or onScaleEnd.
In my Fragment's onCreateView, I initialize:
scaleGestureDetector = new ScaleGestureDetector(getContext(), new simpleOnScaleGestureListener());
And I implement the callbacks like so:
public class simpleOnScaleGestureListener extends
SimpleOnScaleGestureListener {
#Override
public boolean onScale(ScaleGestureDetector detector) {
// TODO Auto-generated method stub
startScale = detector.getScaleFactor();
Log.d(TAG, "::onScale:: " + detector.getScaleFactor());
return true;
}
#Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
// TODO Auto-generated method stub
Log.d(TAG, "::onScaleBegin:: " + detector.getScaleFactor());
return true;
}
#Override
public void onScaleEnd(ScaleGestureDetector detector) {
// TODO Auto-generated method stub
Log.d(TAG, "::onScaleEnd:: " + detector.getScaleFactor());
endScale = detector.getScaleFactor();
}
Also, is it fair to assume that the callbacks will be called continuously whenever the user zooms in/out?
I was able to get past the issue of getting callbacks. Essentially, two things:
In your activity/fragment, implement, GoogleMap.OnCameraIdleListener
In onMapReady(), call mMap.setOnCameraIdleListener(this);
Hence, override onCameraIdle():
#Override
public void onCameraIdle() {
Log.i(TAG, "::onCameraIdle::" + mMap.getCameraPosition().toString());
}
to get lat/long, zoom, tilt and bearing, essentially CameraPosition.
I found a way to get radius in meters by referring to this response
VisibleRegion vr = map.getProjection().getVisibleRegion();
Location center = new Location("center");
center.setLatitude(vr.latLngBounds.getCenter().latitude);
center.setLongitude(vr.latLngBounds.getCenter().longitude);
//Location("radiusLatLng") as mentioned in google maps sample
Location farVisiblePoint = new Location("radiusLatLng");
farVisiblePoint.setLatitude(vr.farLeft.latitude);
farVisiblePoint.setLongitude(vr.farLeft.longitude);
radius = center.distanceTo(farVisiblePoint);
I have basically implemented the new XE12\GDK2 cameramanager sample code to capture an image on application start. However, the notification to the FileObserver callback takes anywhere from 3 to 30 seconds to get the notification of the image file creation. Taking a picture using the default 'Take a Picture' app works just fine so I dont thin it is an OS\update issue.
My app's behavior is like:
- Take the picture
- Tap to accept
Wait 3 to 30 seconds
- Get the callback and the imageview is updated with the captured image.
I dont think I have modified a single line of the sample code provided in the GDK 2.0 camera tutorial. So wondering what I am missing.
I have attached the relevant section of the code below. Any tips\pointers highly appreciated.
#Override
protected void onStart() {
super.onStart();
Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
// String path = Environment.getExternalStorageDirectory().getPath();
if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE);
}
}
private void processPictureWhenReady(final String picturePath) {
final File pictureFile = new File(picturePath);
if (pictureFile.exists()) {
// The picture is ready; process it. Takes 3-30 seconds to get here!
try {
Bitmap imageBitmap = BitmapFactory.decodeFile(picturePath);
int w = imageBitmap.getWidth();
int h = imageBitmap.getHeight();
Bitmap bm2 = Bitmap.createScaledBitmap(imageBitmap, w/2, h/2, true);
imageBitmap = bm2.copy(bm2.getConfig(), true);
//m_ImageView.setImageBitmap(bm2);
} catch (Exception e) {
Log.e("Exc", e.getMessage());
}
} else {
tm = System.currentTimeMillis();
// The file does not exist yet. Before starting the file observer, you
// can update your UI to let the user know that the application is
// waiting for the picture (for example, by displaying the thumbnail
// image and a progress indicator).
final File parentDirectory = pictureFile.getParentFile();
FileObserver observer = new FileObserver(parentDirectory.getPath()) {
// Protect against additional pending events after CLOSE_WRITE is
// handled.
private boolean isFileWritten;
#Override
public void onEvent(int event, String path) {
if (!isFileWritten) {
// For safety, make sure that the file that was created in
// the directory is actually the one that we're expecting.
File affectedFile = new File(parentDirectory, path);
isFileWritten = (event == FileObserver.CLOSE_WRITE
&& affectedFile.equals(pictureFile));
if (isFileWritten) {
stopWatching();
// Now that the file is ready, recursively call
// processPictureWhenReady again (on the UI thread).
runOnUiThread(new Runnable() {
#Override
public void run() {
processPictureWhenReady(picturePath);
}
});
}
}
}
};
observer.startWatching();
}
}
Answering my own question - though I got the clarifications from Jenny Murphy and John Feig :-). Hopefully it helps others.
To the first point - why is image capture using the sample code from the GDK guide so slow:
This is the expected behavior. The Glass camera intent (ACTION_IMAGE_CAPTURE) performs a ton of proprietary post-processing on the captured image - auto-HDR etc which takes time. This is cleverly disguised in the 'Take a picture' command by only displaying the preview image (which is available immediately.). As proof, try to find the image you just took in your time-line. You will not see it for several seconds (around 8 seconds on average in my experience.).
Frankly, unless you are ok just grabbing the preview image, the camera intent may not be very useful in most apps.
The solution is to use the Camera directly using default Android APIs. For convenience, I have pasted a snippet of this code. Please excuse if it is kind of basic for many of you. A lot of the code is copied from John Feig's GIFCamera glassware on GitHub
activity_main layout contains a SurfaceView called preview
<SurfaceView
android:id="#+id/preview"
android:layout_width="500dp"
android:layout_height="500dp"
android:layout_alignParentTop="true"
android:layout_marginTop="20dp"
/>
MainActivity.java
public class MainActivity extends Activity implements PhotoCallback {
public byte[] m_jpg = null;
Camera cam = null;
SurfaceHolder m_sh;
private final SurfaceHolder.Callback mSurfaceHolderCallback = new SurfaceHolder.Callback() {
#Override
public void surfaceCreated(SurfaceHolder hldr) {
m_sh = hldr;
}
#Override
public void surfaceDestroyed(SurfaceHolder holder) {
}
#Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
myCapHandler2(); //Start Camera Preview etc.
}
};
#Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
SurfaceView preview = (SurfaceView) findViewById(R.id.preview);
preview.getHolder().addCallback(mSurfaceHolderCallback);
}
public void myCapHandler2() {
//open camera
try {
cam = Camera.open(0);
Camera.Parameters params = cam.getParameters();
List<Size> sizes = params.getSupportedPreviewSizes();
params.setJpegQuality(90);
params.setPreviewFpsRange(30000, 30000);
params.setPictureSize(sizes.get(1).width, sizes.get(1).height);
params.setPreviewSize(sizes.get(1).width, sizes.get(1).height);
cam.setParameters(params);
try {
cam.setPreviewDisplay(m_sh);
}
catch (IOException e) {
e.printStackTrace();
}
// Important: Call startPreview() to start updating the preview
// surface. Preview must be started before you can take a picture.
cam.startPreview();
cam.takePicture(null, null,
new PhotoHandler(this));
} catch (Exception e) {
if (null != cam) {
cam.stopPreview();
cam.release();
}
}
}
#Override
public void pictureTaken(byte[] jpg) {
m_jpg = jpg;
//Picture captured - release the camera for other apps
cam.stopPreview();
cam.release();
}
#Override
public void onPause() {
if (null != cam) {
cam.stopPreview();
cam.release();
}
}
#Override
public void onDestroy() {
if (null != cam) {
cam.stopPreview();
cam.release();
}
}
}
PhotoHandler.java
import android.hardware.Camera;
import android.os.AsyncTask;
public class PhotoHandler implements Camera.PictureCallback {
private PhotoCallback photoCallback;
public PhotoHandler(PhotoCallback photoCallback) {
super();
this.photoCallback = photoCallback;
}
#Override
public void onPictureTaken(byte[] data, Camera camera) {
new ProcessCapturedImage().execute(data);
}
private class ProcessCapturedImage extends AsyncTask<byte[], Void, byte[]> {
#Override
protected byte[] doInBackground(byte[]... params) {
if (null == params || null == params[0])
return null;
return params[0];
}
#Override
protected void onPostExecute(byte[] params) {
photoCallback.pictureTaken(params);
}
}
}
PhotoCallback.java
public interface PhotoCallback {
public void pictureTaken(byte[] jpg);
}
All the best with your camera glassware.
Does anyone know if when you use the GDK Cameramanager Intent to take a picture, is there a way to not show the preview or close it automatically? Capturing an image for use in app and don't want to have to tap to accept.
I probably have missed something.
Thanks,
You can try this:
Intent localIntent = new Intent("com.google.glass.action.TAKE_PICTURE_FROM_SCREEN_OFF");
localIntent.putExtra("should_finish_turn_screen_off", true);
localIntent.putExtra("should_take_picture", true);
localIntent.putExtra("screenshot_file_path", pathToFile);
startActivity(localIntent);
It will close your preview automatically after few seconds.
try this...
private void takePicture() {
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
startActivityForResult(intent, 0);
}
#Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == 0 && resultCode == RESULT_OK) {
String picturePath=data.getStringExtra(CameraManager.EXTRA_PICTURE_FILE_PATH);
processPictureWhenReady(picturePath);
}
super.onActivityResult(requestCode, resultCode, data);
}
private void processPictureWhenReady(final String picturePath) {
final File pictureFile = new File(picturePath);
if (pictureFile.exists()) {
// The picture is ready; process it.
// Write your code here
} else {
final File parentDirectory = pictureFile.getParentFile();
FileObserver observer = new FileObserver(parentDirectory.getPath()) {
// Protect against additional pending events after CLOSE_WRITE is
// handled.
private boolean isFileWritten;
#Override
public void onEvent(int event, String path) {
if (!isFileWritten) {
// For safety, make sure that the file that was created in
// the directory is actually the one that we're expecting.
File affectedFile = new File(parentDirectory, path);
isFileWritten = (event == FileObserver.CLOSE_WRITE
&& affectedFile.equals(pictureFile));
if (isFileWritten) {
stopWatching();
// Now that the file is ready, recursively call
// processPictureWhenReady again (on the UI thread).
runOnUiThread(new Runnable() {
#Override
public void run() {
processPictureWhenReady(picturePath);
}
});
}
}
}};
observer.startWatching();
}
}
Speaking "ok glass" brings up a command list that automatically scrolls based on the user's head motion.
Is there a built-in UI element in the GDK that implements this? Or will I have to write my own code that uses sensors?
I tried reimplementing parts of this. It's not as shiny as the google one, but those could serve as a starting point:
https://github.com/pscholl/glass_snippets/blob/master/lib/src/main/java/de/tud/ess/HeadListView.java
https://github.com/pscholl/glass_snippets/blob/master/lib/src/main/java/de/tud/ess/HeadScrollView.java
I went through the GDK's Developer Guides at https://developers.google.com/glass/develop/gdk/dev-guides and Reference at https://developers.google.com/glass/develop/gdk/reference/index and there's definitely no such built-in UI elements in GDK, as of XE 12 released in December 2013.
So the answer for now is yes you have to use sensors to implement that.
There is currently no native GDK UI element for scrolling a list using sensors (in fact, according to this issue, use of ListView at all appears to be discouraged).
However, I was able to get the following to work reasonably well in my app. My list is fixed at 4 elements (which helps determine how much scrolling happens), so you can tweak this accordingly (see comments).
import com.google.android.glass.media.Sounds;
import com.google.android.glass.touchpad.Gesture;
import com.google.android.glass.touchpad.GestureDetector;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.media.AudioManager;
import android.view.MotionEvent;
import android.widget.ListView;
/**
* Implements sensor-based scrolling of a ListView
*/
public class SensorListController implements SensorEventListener, GestureDetector.BaseListener {
static final String TAG = "SensorListController";
Context mContext;
ListView mList;
SensorManager mSensorManager;
private float[] mRotationMatrix = new float[16];
private float[] mOrientation = new float[9];
private float[] history = new float[2];
private float mHeading;
private float mPitch;
boolean mActive = true;
GestureDetector mGestureDetector;
public SensorListController(Context context, ListView list) {
this.mContext = context;
this.mList = list;
history[0] = 10;
history[1] = 10;
mGestureDetector = new GestureDetector(mContext);
mGestureDetector.setBaseListener(this);
}
/**
* Receive pass-through of event from View
*/
public boolean onMotionEvent(MotionEvent event) {
return mGestureDetector.onMotionEvent(event);
}
#Override
public boolean onGesture(Gesture gesture) {
switch (gesture) {
case TWO_LONG_PRESS:
// Toggle on and off accelerometer control of the list by long press
playSuccessSound();
toggleActive();
return true;
case TWO_TAP:
// Go to top of the list
playSuccessSound();
scrollToTop();
return true;
}
return false;
}
/**
* Should be called from the onResume() of Activity
*/
public void onResume() {
mSensorManager = (SensorManager) mContext.getSystemService(Context.SENSOR_SERVICE);
mSensorManager.registerListener(this,
mSensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR),
SensorManager.SENSOR_DELAY_UI);
}
/**
* Should be called from the onPause() of Activity
*/
public void onPause() {
mSensorManager.unregisterListener(this);
}
/**
* Toggles whether the controller modifies the view
*/
public void toggleActive() {
mActive = !mActive;
}
#Override
public void onSensorChanged(SensorEvent event) {
if (mList == null || !mActive) {
return;
}
if (event.sensor.getType() == Sensor.TYPE_ROTATION_VECTOR) {
SensorManager.getRotationMatrixFromVector(mRotationMatrix, event.values);
SensorManager.remapCoordinateSystem(mRotationMatrix, SensorManager.AXIS_X,
SensorManager.AXIS_Z, mRotationMatrix);
SensorManager.getOrientation(mRotationMatrix, mOrientation);
mHeading = (float) Math.toDegrees(mOrientation[0]);
mPitch = (float) Math.toDegrees(mOrientation[1]);
float xDelta = history[0] - mHeading; // Currently unused
float yDelta = history[1] - mPitch;
history[0] = mHeading;
history[1] = mPitch;
float Y_DELTA_THRESHOLD = 0.13f;
// Log.d(TAG, "Y Delta = " + yDelta);
int scrollHeight = mList.getHeight()
/ 19; // 4 items per page, scroll almost 1/5 an item
// Log.d(TAG, "ScrollHeight = " + scrollHeight);
if (yDelta > Y_DELTA_THRESHOLD) {
// Log.d(TAG, "Detected change in pitch up...");
mList.smoothScrollBy(-scrollHeight, 0);
} else if (yDelta < -Y_DELTA_THRESHOLD) {
// Log.d(TAG, "Detected change in pitch down...");
mList.smoothScrollBy(scrollHeight, 0);
}
}
}
#Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
private void scrollToTop() {
mList.smoothScrollToPosition(0);
}
private void playSuccessSound() {
// Play sound to acknowledge action
AudioManager audio = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
audio.playSoundEffect(Sounds.SUCCESS);
}
}
I used the above in a ListActivity. I initialize it in onCreate(), and here is the method that initializes it:
private void initListController() {
mListView = getListView();
mListView.setChoiceMode(ListView.CHOICE_MODE_NONE);
mListView.setSelector(android.R.color.transparent);
mListView.setClickable(true);
mListController = new SensorListController(this, mListView);
}
This also removes the selection indicator from view by making it transparent.
The above controller also uses two finger press to pause/resume scrolling, and a two finger tap to scroll to the top of the list (and acknowledges both these actions with a sound). Note that for these gestures to work, you will need to override onGenericMotionEvent() in your Activity and pass through the event, like:
#Override
public boolean onGenericMotionEvent(MotionEvent event) {
// We need to pass events through to the list controller
if (mListController != null) {
return mListController.onMotionEvent(event);
}
return false;
}
Full source code for this solution can be seen on Github, and the APK can be downloaded here.