blob: 2e6e21b27cfabeea172573bc26eb7d0c57237143 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package io.v.moments.ux;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.support.design.widget.FloatingActionButton;
import android.support.v4.view.MenuItemCompat;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.SwitchCompat;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.CompoundButton;
import android.widget.Toast;
import com.google.common.util.concurrent.FutureCallback;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import io.v.moments.R;
import io.v.moments.ifc.Advertiser;
import io.v.moments.ifc.Moment;
import io.v.moments.ifc.MomentFactory;
import io.v.moments.lib.DiscoveredList;
import io.v.moments.lib.Id;
import io.v.moments.lib.ObservedList;
import io.v.moments.lib.PermissionManager;
import io.v.moments.lib.V23Manager;
import io.v.moments.model.AdConverterMoment;
import io.v.moments.model.AdvertiserFactory;
import io.v.moments.model.BitMapper;
import io.v.moments.model.Config;
import io.v.moments.model.FileUtil;
import io.v.moments.model.MomentFactoryImpl;
import io.v.moments.model.StateStore;
import io.v.v23.context.VContext;
import io.v.v23.security.Blessings;
/**
* This app allows the user to take photos and advertise them on the network.
* Other instances of the app will scan for and display such photos.
*
* This app is an example of running, advertising and scanning for multiple
* services.
*
* A photo and its ancillary data (id, author, caption, date, ordinal number,
* etc.) are called a Moment. There are _local_ moments, created locally, and
* _remote_ moments, found via discovery. Local moments can be advertised so
* that remote devices can discover and request them. Remote moments are not
* re-advertised.
*
* Local moments are persistent, in that the user must delete them to get rid of
* them. Remote moments are pulled in over the network, and not officially
* retained between runs, though remote photo data may be left in local storage
* as a simple cache. The moment's id is used to invalidate the cache.
*
* Every local moment is served by its own service. This egregious use of
* services allows for easy creation of multiple scan targets to exercise
* discovery code. The involvement of a photo encourages the use of an RPC since
* adding a (very large) photo to an advertisement as an attribute noticeably
* increases the time taken between clicking 'advertise' on one device and
* seeing any evidence of the advertisement on another device.
*/
public class MainActivity extends AppCompatActivity implements CompoundButton.OnCheckedChangeListener {
private static final String TAG = "MainActivity";
// Android Marshmallow permissions list.
private static final String[] PERMS = {
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.BLUETOOTH_ADMIN,
};
// A string that unambiguously identifies the phone in human readable form.
private static final String DEVICE_SIGNATURE = Build.MODEL + " " + Build.SERIAL;
// For Marshmallow permissions.
private final PermissionManager mPermissionManager
= new PermissionManager(this, RequestCode.PERMISSIONS, PERMS);
// Use a serial executor to assure serial execution, e.g. toggling a switch
// on and off without a race condition.
private final ExecutorService mSerialExecutor = Executors.newSingleThreadExecutor();
// Use a pool when order isn't important.
private final ExecutorService mPoolExecutor = Executors.newCachedThreadPool();
// For changes to UX.
private final Handler mHandler = new Handler(Looper.getMainLooper());
// For discovery, serving and behaving as a client.
private final V23Manager mV23Manager = V23Manager.Singleton.get();
// See wireUxToDataModel for discussion of the following.
private StateStore mStateStore;
private AdvertiserFactory mAdvertiserFactory;
private MomentFactory mMomentFactory;
private BitMapper mBitMapper;
private ObservedList<Moment> mLocalMoments;
private DiscoveredList<Moment> mRemoteMoments;
private VContext mScanCtx;
private boolean mShouldBeScanning;
private Id mCurrentPhotoId;
/**
* The number used in a moment's file name is called the moments 'ordinal'
* number. At the time of writing local moments are never individually
* deleted - either they are all kept or all deleted when app data is
* deleted. Therefore, the _next_ local ordinal is just an array size
* increment.
*
* Remote (discovered) moments, however, come and go individually, so one
* cannot use, say, the size of the current list of remote moments to
* determine the next ordinal number, as one might use a number already in
* use, and display the wrong photo.
*
* When the phone is rotated, discovery scanning stops, then restarts. So
* known moments are immediately 're-'discovered. To avoid reacquiring
* data, retain and use a cache of known remote moments. When a discovery
* is made, check the map, and if there's a hit, there's no need to contact
* the remote service. The cache can also be used to compute the 'next'
* ordinal number for remote services.
*/
private Map<Id, Moment> mRemoteMomentCache;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
logState("onCreate");
setContentView(R.layout.activity_main);
// This will look in prefs for a Vanadium blessing, and if not
// found will leave this activity (onStop likely to be called) and
// return via a start intent (not via onActivityResult).
mV23Manager.init(this, onBlessings());
wireUxToDataModel();
initializeOrRestore(savedInstanceState);
}
private FutureCallback<Blessings> onBlessings() {
return new FutureCallback<Blessings>() {
@Override
public void onSuccess(Blessings b) {
Log.d(TAG, "Got blessings!");
if (!mPermissionManager.haveAllPermissions()) {
Log.d(TAG, "Obtaining permissions");
mPermissionManager.obtainPermission();
}
}
@Override
public void onFailure(Throwable t) {
Log.d(TAG, "Failure to get blessings, nothing will work.", t);
}
};
}
/**
* This method builds the app's object graph, and intentionally has no
* branches that depend on state loaded from the app's prefs or instance
* bundle. State is loaded after the wiring is complete, to make phone
* rotation easy.
*/
private void wireUxToDataModel() {
// Stores remote moments by Id to avoid having to wait for re-discovery.
mRemoteMomentCache = new HashMap<>();
// Compresses byte data, converts byte[] to bitmap, manages file storage.
mBitMapper = Config.makeBitmapper(this);
// Makes advertisers. Needs v23Manager to do advertising.
mAdvertiserFactory = new AdvertiserFactory(mV23Manager);
// Makes moments. Each moment needs a bitmapper to read its BitMaps.
mMomentFactory = new MomentFactoryImpl(mBitMapper);
// Local moments, with photos taken by the local device.
mLocalMoments = new ObservedList<>();
// Converts advertisements to 'remote' moments. Needs v23Manager to
// make RPCs, needs mMomentFactory to make moments, needs a thread pool
// for making RPCs to get photos, needs mHandler to post photos on the
// UX thread, fills the cache with remote moments.
AdConverterMoment converter = new AdConverterMoment(
mV23Manager, mMomentFactory, mPoolExecutor,
mHandler, mRemoteMomentCache);
// The list of remote (discovered) moments. Pass mAdvertiserFactory
// as a container of Id's to reject from discovery (because they
// represent local moments).
mRemoteMoments = new DiscoveredList<>(converter, mAdvertiserFactory, mHandler);
// Stores app state to bundles, preferences, etc. The mMomentFactory
// needed to recreate moments.
mStateStore = new StateStore(
getSharedPreferences(
nameOfSharedPrefs(), Context.MODE_PRIVATE),
mMomentFactory);
// Tell the converter where to place remote moments.
converter.setList(mRemoteMoments);
// The adapter allows remote and local moment lists to 'stack' in a
// RecyclerView. mAdvertiserFactory is used to generate advertisers
// for local moments when a user wants to advertise them. The
// serialExecutor is used to start/stop advertisements in the UX.
MomentAdapter adapter = new MomentAdapter(
mRemoteMoments, mLocalMoments,
mAdvertiserFactory, mHandler);
// Lets the adapter speed up a bit.
adapter.setHasStableIds(true);
// Hook the adapter to a view, and begin observing changes to the
// moment lists.
RecyclerView view = configureRecyclerView();
view.setAdapter(adapter);
adapter.beginObserving();
// Expose the scan switch on the action bar.
setSupportActionBar((Toolbar) findViewById(R.id.toolbar));
// Expose the camera button.
setFabClickHandler(new View.OnClickListener() {
@Override
public void onClick(View view) {
takePhoto();
}
});
}
private void setFabClickHandler(View.OnClickListener listener) {
FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
fab.setOnClickListener(listener);
}
@Override
public void onRestart() {
super.onRestart();
logState("onRestart");
}
@Override
public void onPause() {
super.onPause();
logState("onPause");
}
@Override
public void onStop() {
super.onStop();
logState("onStop");
}
/**
* Before calling this, must have permission to read/write to storage, to
* read/write photo data. Don't need camera permission, since a new activity
* is started to actually take the photo.
*/
private void takePhoto() {
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
mCurrentPhotoId = Id.makeRandom();
Integer ordinal = mLocalMoments.size() + 1;
Uri uri = mBitMapper.getCameraPhotoUri(ordinal);
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
startActivityForResult(intent, RequestCode.CAPTURE_IMAGE);
}
/**
* On new permissions, assume it's a fresh install and wipe the working
* directory. File formats and naming might have changed. This is not
* good for permanent photo management obviously.
*/
@Override
public void onRequestPermissionsResult(
int requestCode, String[] permissions, int[] results) {
logState("onRequestPermissionsResult");
if (mPermissionManager.granted(requestCode, permissions, results)) {
FileUtil.initializeDirectory(
Config.getWorkingDirectory(this));
return;
}
mHandler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(
MainActivity.this,
R.string.need_permissions,
Toast.LENGTH_LONG).show();
finish();
}
});
}
/**
* A more realistic example would obtain the phone owner's name or email
* address from, say, the contacts list. Instead using a device
* identifier.
*/
public String getAuthorName() {
return DEVICE_SIGNATURE;
}
/**
* Could prompt the user for this; using a label derived from the id
* instead.
*/
public String getCaption(int index) {
return "Generated caption " + index;
}
@Override
protected void onStart() {
super.onStart();
logState("onStart");
if (mLocalMoments.isEmpty()) {
Log.d(TAG, "Loading moments from prefs.");
mStateStore.prefsLoad(mLocalMoments);
}
if (mShouldBeScanning && !isScanning()) {
startScanning();
}
}
@Override
public void onResume() {
super.onResume();
logState("onResume");
}
@Override
protected void onDestroy() {
super.onDestroy();
logState("onDestroy");
if (isScanning()) {
stopScanning();
}
stopAllAdvertising();
Log.d(TAG, "Destruction complete.");
}
private void stopAllAdvertising() {
int count = 0;
for (Advertiser advertiser : mAdvertiserFactory.allAdvertisers()) {
if (advertiser.isAdvertising()) {
try {
advertiser.advertiseStop();
count++;
} catch (Exception e) {
e.printStackTrace();
}
Log.d(TAG, "Stopped advertising " + advertiser.toString());
} else {
Log.d(TAG, "A moment was not advertising");
}
}
if (count > 0) {
// This toast noisy if not debugging.
// toast("Stopped " + count + " advertisements.");
}
Log.d(TAG, "Stopped " + count + " advertisements.");
}
private RecyclerView configureRecyclerView() {
RecyclerView view = (RecyclerView) findViewById(R.id.all_moments);
view.setLayoutManager(makeLayoutManager());
// Add some lines between items
view.addItemDecoration(new
DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST));
// This makes it faster, but at a cost.
view.setHasFixedSize(true);
return view;
}
private LinearLayoutManager makeLayoutManager() {
LinearLayoutManager mgr = new LinearLayoutManager(this);
mgr.setOrientation(LinearLayoutManager.VERTICAL);
mgr.scrollToPosition(0);
return mgr;
}
private boolean isScanning() {
return mScanCtx != null;
}
private Runnable setScanningSwitch(final boolean on) {
return new Runnable() {
@Override
public void run() {
SwitchCompat sw = (SwitchCompat) findViewById(R.id.action_scan);
if (sw != null) {
sw.setChecked(on);
if (on) {
toast("Started scanning.");
} else {
toast("Stopped scanning.");
}
}
}
};
}
private void toast(final String msg) {
mHandler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(getApplicationContext(), msg, Toast.LENGTH_SHORT).show();
}
});
}
private void startScanning() {
if (isScanning()) {
throw new IllegalStateException("Already scanning.");
}
mSerialExecutor.submit(new Runnable() {
@Override
public void run() {
Log.d(TAG, "Starting scan.");
mScanCtx = mV23Manager.scan(Config.QUERY, mRemoteMoments);
runOnUiThread(setScanningSwitch(true));
Log.d(TAG, "Scan started.");
}
});
}
private void stopScanning() {
if (!isScanning()) {
throw new IllegalStateException("Not scanning.");
}
mSerialExecutor.submit(new Runnable() {
@Override
public void run() {
Log.d(TAG, "Stopping scan.");
mScanCtx.cancel();
mScanCtx = null;
mRemoteMoments.dropAll();
runOnUiThread(setScanningSwitch(false));
Log.d(TAG, "Scan stopped.");
}
});
}
@Override
public void onCheckedChanged(CompoundButton button, boolean isChecked) {
if (button.getId() != R.id.action_scan) {
throw new IllegalStateException("Bad scan wiring.");
}
if (isChecked) {
mShouldBeScanning = true;
if (!isScanning()) {
startScanning();
}
} else {
mShouldBeScanning = false;
if (isScanning()) {
stopScanning();
}
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_main, menu);
MenuItem item = menu.findItem(R.id.action_scan);
((SwitchCompat) MenuItemCompat.getActionView(item)).setOnCheckedChangeListener(this);
return true;
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
logState("onActivityResult");
if (requestCode == RequestCode.CAPTURE_IMAGE) {
if (resultCode == RESULT_OK) {
processCapturedPhoto();
}
}
}
private void processCapturedPhoto() {
int ordinal = mLocalMoments.size() + 1;
final Moment moment = mMomentFactory.make(
mCurrentPhotoId, ordinal,
getAuthorName(), getCaption(ordinal));
Log.d(TAG, "pcp: moment = " + moment);
Log.d(TAG, "pcp: list size = " + mLocalMoments.size());
mLocalMoments.push(moment);
mSerialExecutor.submit(new Runnable() {
@Override
public void run() {
mBitMapper.dealWithCameraResult(
mLocalMoments, moment);
}
});
}
@Override
protected void onSaveInstanceState(Bundle b) {
super.onSaveInstanceState(b);
logState("onSaveInstanceState");
b.putBoolean(B.SHOULD_BE_SCANNING, mShouldBeScanning);
mStateStore.bundleSave(b, mRemoteMomentCache.values());
mStateStore.prefsSave(mLocalMoments);
}
private void initializeOrRestore(Bundle b) {
mStateStore.prefsLoad(mLocalMoments);
if (b == null) {
Log.d(TAG, "No bundle passed, starting fresh.");
mShouldBeScanning = false;
mRemoteMomentCache.clear();
return;
}
Log.d(TAG, "Reloading from bundle.");
mShouldBeScanning = b.getBoolean(B.SHOULD_BE_SCANNING, false);
mStateStore.bundleLoad(b, mRemoteMomentCache);
}
private String nameOfSharedPrefs() {
return getClass().getPackage() +
"." + getString(R.string.photo_file_prefix);
}
private void logState(String state) {
Log.d(TAG, state + " --------------------------------------------");
}
private static class RequestCode {
static final int PERMISSIONS = 1001;
static final int CAPTURE_IMAGE = 1003;
}
private static class B {
static final String SHOULD_BE_SCANNING = "SHOULD_BE_SCANNING";
}
}