package io.nextop; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.hardware.Camera; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.Bundle; import com.google.common.annotations.Beta; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.cache.Weigher; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import io.nextop.client.MessageContext; import io.nextop.client.MessageContexts; import io.nextop.client.MessageControlNode; import io.nextop.client.MessageControlState; import io.nextop.client.node.Head; import io.nextop.client.node.MultiNode; import io.nextop.client.node.http.HttpNode; import io.nextop.client.node.nextop.NextopClientWireFactory; import io.nextop.client.node.nextop.NextopNode; import rx.Observable; import rx.Scheduler; import rx.Subscriber; import rx.android.schedulers.AndroidSchedulers; import rx.functions.Action0; import rx.functions.Func1; import rx.schedulers.Schedulers; import rx.subjects.BehaviorSubject; import javax.annotation.Nullable; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.*; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; // FIXME calls all receives on the MAIN thread // FIXME work out scheduling in general (all signatures should take a scheduler) @Beta public class Nextop { /** The android:name to use in application meta-data to set the access key * @see #create(android.content.Context) */ public static final String M_ACCESS_KEY = "NextopAccessKey"; /** The android:name to use in application meta-data to set the grant key(s). * Can point to a single string or an string array. * @see #create(android.content.Context) */ public static final String M_GRANT_KEYS = "NextopGrantKeys"; public static Nextop create(Context context) { try { @Nullable Bundle metaData = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA).metaData; if (null != metaData) { @Nullable String accessKey = metaData.getString(M_ACCESS_KEY); @Nullable String[] grantKeys = metaData.getStringArray(M_GRANT_KEYS); if (null == grantKeys) { @Nullable String oneGrantKey = metaData.getString(M_GRANT_KEYS); if (null != oneGrantKey) { grantKeys = new String[]{oneGrantKey}; } } return create(context, Auth.create(accessKey, grantKeys)); } else { return create(context, (Auth) null); } } catch (IllegalArgumentException e) { // FIXME log this return create(context, (Auth) null); } catch (PackageManager.NameNotFoundException e) { // FIXME log this return create(context, (Auth) null); } } public static Nextop create(Context context, String accessKey, String ... grantKeys) { return create(context, Auth.create(accessKey, grantKeys)); } private static Nextop create(Context context, @Nullable Auth auth) { return new Nextop(context, auth); } private static Nextop create(Context context, Nextop copy) { return new Nextop(context, copy.auth); } protected final Context context; @Nullable protected final Auth auth; private Nextop(Context context, @Nullable Auth auth) { this.context = context; this.auth = auth; } @Nullable public Auth getAuth() { return auth; } @Nullable public MessageControlState getMessageControlState() { return null; } public Nextop start() { // FIXME 0.2 see roadmap // if (null != auth) { // // in this case the access key might still be bad/disabled/unreachable, // // and the client will fall back // return Full.start(this); // } else { // in this case the client won't waste time negotiating with the nextop service // it will start in fall back return Limited.start(this); // } } /** Typically this should not be called outside of testing - an app should run indefinitely until terminated by the OS. * If called, further calls on this object will result in unspecified behavior. * Use the returned object to restart the client. */ public Nextop stop() { return this; } boolean isActive() { return false; } public Receiver<Message> send(Message message) { throw new IllegalStateException("Call on a started nextop."); } public Receiver<Message> receive(Route route) { throw new IllegalStateException("Call on a started nextop."); } public void cancelSend(Id id) { throw new IllegalStateException("Call on a started nextop."); } /////// IMAGE /////// // send can be GET for image, POST/PUT of new image // config controls both up and down, when present public Receiver<Layer> send(Layer layer, @Nullable LayersConfig config) { throw new IllegalStateException("Call on a started nextop."); } public static final class LayersConfig { public static LayersConfig send(Bound... bounds) { return new LayersConfig(ImmutableList.copyOf(bounds), ImmutableList.<Bound>of()); } public static LayersConfig receive(Bound... bounds) { return new LayersConfig(ImmutableList.<Bound>of(), ImmutableList.copyOf(bounds)); } /** ordered worst to best quality */ public final List<Bound> sendBounds; /** ordered worst to best quality */ public final List<Bound> receiveBounds; LayersConfig(List<Bound> sendBounds, List<Bound> receiveBounds) { this.sendBounds = sendBounds; this.receiveBounds = receiveBounds; } public LayersConfig andSend(Bound... bounds) { return new LayersConfig(ImmutableList.copyOf(bounds), receiveBounds); } public LayersConfig andReceive(Bound... bounds) { return new LayersConfig(sendBounds, ImmutableList.copyOf(bounds)); } public LayersConfig copy() { List<Bound> sendBoundsCopy = new ArrayList<Bound>(sendBounds.size()); List<Bound> receiveBoundsCopy = new ArrayList<Bound>(receiveBounds.size()); for (Bound sendBound : sendBounds) { sendBoundsCopy.add(sendBound.copy()); } for (Bound receiveBound : receiveBounds) { receiveBoundsCopy.add(receiveBound.copy()); } return new LayersConfig(ImmutableList.copyOf(sendBoundsCopy), ImmutableList.copyOf(receiveBoundsCopy)); } public static String toCacheKey(String uri, Bound bound) { return String.format("%s:%s:%s:%s", 0 <= bound.maxTransferWidth ? bound.maxTransferWidth : "", 0 <= bound.maxTransferHeight ? bound.maxTransferHeight : "", bound.quality, uri); } // once passed off, consider this immutable public static final class Bound { // TRANSFER // affects url public int maxTransferWidth = -1; // affects url public int maxTransferHeight = -1; // the layer is ignored if the transferred size is less than this public int minTransferWidth = -1; // the layer is ignored if the transferred size is less than this public int minTransferHeight = -1; // affects url // [0, 100] public int quality = 100; public Id groupId = Message.DEFAULT_GROUP_ID; // affects transmission public int groupPriority = Message.DEFAULT_GROUP_PRIORITY; // DISPLAY // does not affect cache url; decode only public int maxWidth = -1; // does not affect cache url; decode only public int maxHeight = -1; public Bound copy() { Bound copy = new Bound(); copy.maxTransferWidth = maxTransferWidth; copy.maxTransferHeight = maxTransferHeight; copy.minTransferWidth = minTransferWidth; copy.minTransferHeight = minTransferHeight; copy.quality = quality; copy.groupId = groupId; copy.groupPriority = groupPriority; copy.maxWidth = maxWidth; copy.maxHeight = maxHeight; return copy; } } } public static final class Layer { public static Layer message(Message message) { return message(message, true); } public static Layer message(Message message, boolean last) { return new Layer(message, null, last); } public static Layer bitmap(Message message, Bitmap bitmap) { return bitmap(message, bitmap, true); } public static Layer bitmap(Message message, Bitmap bitmap, boolean last) { return new Layer(message, bitmap, last); } public final Message message; @Nullable public final Bitmap bitmap; public final boolean last; Layer(Message message, @Nullable Bitmap bitmap, boolean last) { if (null == message) { throw new IllegalArgumentException(); } this.message = message; this.bitmap = bitmap; this.last = last; } } /////// CONNECTION STATUS /////// public Observable<ConnectionStatus> connectionStatus() { BehaviorSubject<ConnectionStatus> subject = BehaviorSubject.create(new ConnectionStatus(true)); return subject; } public static final class ConnectionStatus { public final boolean online; ConnectionStatus(boolean online) { this.online = online; } } /////// TRANSFER STATUS /////// public Observable<TransferStatus> transferStatus(Id id) { // if (null == id) { // throw new IllegalArgumentException(); // } // Message statusMessage = Message.newBuilder().setRoute(Message.statusRoute(id)).build(); // return send(statusMessage).map(new Func1<Message, TransferStatus>() { // @Override // public TransferStatus call(Message message) { // return new TransferStatus(message.parameters.get(Message.P_PROGRESS).asFloat()); // } // }); throw new IllegalStateException("Call on a started nextop."); } /////// TIME /////// private final long millis0 = System.currentTimeMillis(); private final long nanos0 = System.nanoTime(); private long headUniqueMillis = 0L; // a best-guess at a coordinated time, in millis public long millis() { return millis0 + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - nanos0); } // each call guaranteed to be a unique timestamp // colliding times are shifted into the future public long uniqueMillis() { long millis = millis(); if (millis <= headUniqueMillis) { headUniqueMillis += 1; } else { headUniqueMillis = millis; } return headUniqueMillis; } /////// CAMERA /////// /* the Nextop instance can manage the camera, * which (can be) useful to * - align camera performance with network performance * (quality, etc) * - keep a single camera across warmed up across the entire app, * which (can) improve start-up times for the camera * - to reserve the camera, in your activity/fragment resume, call * {@link #addCameraUser} and in pause call {@link #removeCameraUser}, * then (in between) wait for a camera instance with {@link #camera}. */ public void addCameraUser() { throw new IllegalStateException("Call on a started nextop."); } public void removeCameraUser() { throw new IllegalStateException("Call on a started nextop."); } public Observable<CameraAdapter> camera() { return Observable.empty(); } public static final class CameraAdapter { public final int cameraId; public final Camera camera; CameraAdapter(int cameraId, Camera camera) { this.cameraId = cameraId; this.camera = camera; } } private static int getDefaultCameraId() { int numberOfCameras = Camera.getNumberOfCameras(); Camera.CameraInfo cameraInfo = new Camera.CameraInfo(); for (int i = 0; i < numberOfCameras; i++) { Camera.getCameraInfo(i, cameraInfo); if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_BACK) { return i; } } return 1 <= numberOfCameras ? 0 : -1; } static Receiver.UnsubscribeBehavior defaultUnsubscribeBehavior(Message message) { // if the message has no side-effects, it can be canceled when there is no subscriber if (Message.isNullipotent(message)) { return Receiver.UnsubscribeBehavior.CANCEL_SEND; } return Receiver.UnsubscribeBehavior.DETACH; } // FIXME // FIXME the CANCEL_SEND unsubscribe behavior as-is is broken // FIXME the receiver should re-send the message when re-subscribed, if canceled public static final class Receiver<T> extends Observable<T> { static <T> Receiver<T> create(final Nextop nextop, Route route, Observable<T> in, UnsubscribeBehavior defaultUnsubscribeBehavior) { Map<UnsubscribeBehavior, Observable<T>> ins = new HashMap<UnsubscribeBehavior, Observable<T>>(2); final @Nullable Id id = Message.getLocalId(route); if (null != id) { ins.put(UnsubscribeBehavior.DETACH, in.share()); ins.put(UnsubscribeBehavior.CANCEL_SEND, in.doOnUnsubscribe(new Action0() { @Override public void call() { nextop.cancelSend(id); } }).share()); } else { ins.put(UnsubscribeBehavior.DETACH, in.share()); } Source source = new Source<T>(ins); source.check(defaultUnsubscribeBehavior); return new Receiver(route, source, defaultUnsubscribeBehavior); } public final Route route; private final Source<T> source; private Receiver(Route route, final Source<T> source, final UnsubscribeBehavior unsubscribeBehavior) { super(new OnSubscribe<T>() { @Override public void call(Subscriber<? super T> subscriber) { source.ins.get(unsubscribeBehavior).subscribe(subscriber); } }); this.route = route; this.source = source; } public Receiver<T> doOnUnsubscribe(UnsubscribeBehavior behavior) { source.check(behavior); return new Receiver<T>(route, source, behavior); } // return the localId of the outgoing message that this receiver is tied to // return null if there is no outgoing message for this nurl @Nullable public Id getId() { return Message.getLocalId(route); } public static enum UnsubscribeBehavior { CANCEL_SEND, DETACH } private static class Source<T> { final Map<UnsubscribeBehavior, Observable<T>> ins; Source(Map<UnsubscribeBehavior, Observable<T>> ins) { this.ins = ins; } void check(UnsubscribeBehavior unsubscribeBehavior) { if (!ins.containsKey(unsubscribeBehavior)) { throw new UnsupportedOperationException(); } } } } private static class GoNoded extends Nextop { // CAMERA private int cameraUserCount = 0; private int cameraId = -1; @Nullable private Camera camera = null; private boolean cameraConnected = false; private BehaviorSubject<CameraAdapter> cameraSubject = BehaviorSubject.create(); Head head; MessageControlState mcs; MessageControlNode node; protected GoNoded(Context context, @Nullable Auth auth, MessageControlNode node) { super(context, auth); this.node = node; MessageContext messageContext = MessageContexts.create(); mcs = new MessageControlState(messageContext); head = Head.create(messageContext, mcs, node, /* FIXME */ AndroidSchedulers.mainThread()); head.init(null); head.start(); } @Nullable public MessageControlState getMessageControlState() { return mcs; } @Override public Nextop start() { throw new IllegalArgumentException("Already started."); } @Override public Nextop stop() { head.stop(); closeCamera(); return Nextop.create(context, this); } @Override boolean isActive() { return true; } @Override public Receiver<Message> send(Message message) { head.send(message); return receive(message.inboxRoute()); } @Override public Receiver<Message> receive(Route route) { return Receiver.create(this, route, head.receive(route), Receiver.UnsubscribeBehavior.DETACH); } @Override public void cancelSend(Id id) { head.cancelSend(id); // FIXME synchronized (cacheMutex) { inFlight.inverse().remove(id); } } // FIXME // FIXME if these are GETs, do request piggybacking and decoding on multiple threads Object cacheMutex = new Object(); Cache<String, Bitmap> layerCache = CacheBuilder.newBuilder() .maximumWeight(100) .weigher(new Weigher<String, Bitmap>() { @Override public int weigh(String key, Bitmap value) { // FIXME return 1; } }).concurrencyLevel(1 ).build(); // FIXME BiMap<String, Id> inFlight = HashBiMap.create(32); @Override public Receiver<Layer> send(Layer layer, @Nullable LayersConfig config) { // FIXME 0.1.1 // FIXME send layers should manipulate the route here (base route + parameters per layer) // FIXME this has to be coordinated with the receive/decode step // FIXME get threading right and general correctness // bounds to use: List<LayersConfig.Bound> sendBounds; if (null != config) { sendBounds = config.sendBounds; } else { sendBounds = Collections.emptyList(); } if (sendBounds.isEmpty()) { sendBounds = Collections.singletonList(new LayersConfig.Bound()); } List<LayersConfig.Bound> receiveBounds; if (null != config) { receiveBounds = config.receiveBounds; } else { receiveBounds = Collections.emptyList(); } if (receiveBounds.isEmpty()) { receiveBounds = Collections.singletonList(new LayersConfig.Bound()); } // FIXME only for GETs // FIXME even on cache hit, still send a HEAD to check on the cache headers? final boolean cacheable = true; // FIXME start at the last bounds and go down for a cache hit final String uri = layer.message.toUriString(); final String cacheKey = LayersConfig.toCacheKey(uri, sendBounds.get(0)); @Nullable Bitmap cachedBitmap; synchronized (cacheMutex) { cachedBitmap = layerCache.getIfPresent(cacheKey); } if (cacheable && null != cachedBitmap) { return Receiver.create(this, layer.message.inboxRoute(), Observable.just(Layer.bitmap( // FIXME set cache headers Message.newBuilder().setRoute(layer.message.inboxRoute()).build(), cachedBitmap)), defaultUnsubscribeBehavior(layer.message)); } // FIXME option to attach Route route; Receiver.UnsubscribeBehavior defaultUnsubscribeBehavior; @Nullable final Id inFlightId; synchronized (cacheMutex) { inFlightId = inFlight.get(uri); } if (null != inFlightId) { route = Message.inboxRoute(inFlightId); // FIXME attach flow and receiver flow needs to be reworked defaultUnsubscribeBehavior = Receiver.UnsubscribeBehavior.DETACH; } else { Message tmessage; if (null != layer.bitmap) { Bitmap bitmap = layer.bitmap; ByteArrayOutputStream baos = new ByteArrayOutputStream(1024 * 1024); bitmap.compress(Bitmap.CompressFormat.JPEG, 90, baos); byte[] bytes = baos.toByteArray(); EncodedImage image = new EncodedImage(EncodedImage.Format.JPEG, EncodedImage.Orientation.REAR_FACING, bitmap.getWidth(), bitmap.getHeight(), bytes, 0, bytes.length); Message.Builder builder = layer.message.buildOn() .setContent(WireValue.of(image)); Message.setLayers(builder, new Message.LayerInfo(Message.LayerInfo.Quality.LOW, EncodedImage.Format.JPEG, 32, 32, null, 0)); tmessage = builder.build(); } else { tmessage = layer.message; } head.send(tmessage); route = tmessage.inboxRoute(); synchronized (cacheMutex) { inFlight.put(uri, tmessage.id); } defaultUnsubscribeBehavior = defaultUnsubscribeBehavior(tmessage); } // FIXME parallel // tmessage = tmessage.buildOn().setGroupId(Id.create()).build(); // FIXME subject node needs to put dispatch on the MAIN thread. get scheduling everywhere fixed // FIXME (otherwise could miss the receive) Observable<Layer> s = head.receive(route).observeOn(decodeScheduler).map(new Func1<Message, Layer>() { @Override public Layer call(Message message) { WireValue content = message.getContent(); switch (content.getType()) { case IMAGE: EncodedImage image = content.asImage(); // FIXME // FIXME fit the scale correctly to the layer BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inSampleSize = 4; Bitmap bitmap = BitmapFactory.decodeByteArray(image.bytes, image.offset, image.length, opts); synchronized (cacheMutex) { // FIXME correct cache key layerCache.put(cacheKey, bitmap); inFlight.remove(uri); } return Layer.bitmap(message.buildOn().setContent(null).build(), bitmap); default: return Layer.message(message); } } }).observeOn(AndroidSchedulers.mainThread()); // FIXME for the cache defaultUnsubscribeBehavior = Receiver.UnsubscribeBehavior.DETACH; // FIXME for the cache s = s.share(); s.subscribe(); Receiver<Layer> r = Receiver.create(this, route, s, // FIXME can't cancel send on an in-flight attach // FIXME this whole flow needs to be reworked defaultUnsubscribeBehavior ); return r; } Executor decodeExecutor = Executors.newFixedThreadPool(4); Scheduler decodeScheduler = Schedulers.from(decodeExecutor); // FIXME distinct within 1% // FIXME timeout if no first emit after 15s public Observable<TransferStatus> transferStatus(final Id id) { if (null == id) { throw new IllegalArgumentException(); } // FIXME scheduler issues return mcs.getObservable(id, 30, TimeUnit.SECONDS).subscribeOn(AndroidSchedulers.mainThread()).map(new Func1<MessageControlState.Entry, TransferStatus>() { @Override public TransferStatus call(MessageControlState.Entry entry) { TransferStatus s = new TransferStatus(entry.outboxTransferProgress, entry.inboxTransferProgress); // FIXME remove // System.out.printf(" transfer status %s %s\n", id, s); return s; } }); } /////// CAMERA /////// @Override public void addCameraUser() { ++cameraUserCount; lockCamera(); } @Override public void removeCameraUser() { if (0 == --cameraUserCount) { closeCamera(); } } @Override public Observable<CameraAdapter> camera() { return cameraSubject; } void lockCamera() { openCamera(); try { if (!cameraConnected) { if (cameraId < 0) { cameraId = getDefaultCameraId(); } if (null == camera) { if (0 <= cameraId) { try { camera = Camera.open(cameraId); if (null != camera) { cameraConnected = true; cameraSubject.onNext(new CameraAdapter(cameraId, camera)); } } catch (Exception e) { // e.g. Fail to connect to camera service } } } else { camera.reconnect(); cameraConnected = true; cameraSubject.onNext(new CameraAdapter(cameraId, camera)); } } } catch (IOException e) { // } } void unlockCamera() { if (cameraConnected) { // camera.release(); cameraConnected = false; camera.unlock(); cameraSubject.onCompleted(); cameraSubject = BehaviorSubject.create(); } } void openCamera() { if (null == camera) { try { camera = Camera.open(); } catch (Exception e) { // e.g. Fail to connect to camera service } } } void closeCamera() { unlockCamera(); if (null != camera) { cameraId = -1; camera.release(); camera = null; } } // FIXME 0.1.1 // FIXME use broadcasts to receive connectivity messages // FIXME for now, just poll this: // FIXME demo hack public boolean isOnline() { ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); return activeNetworkInfo != null && activeNetworkInfo.isConnected(); } } // FIXME 0.2 see roadmap // private static final class Full extends GoNoded { // static Full start(Nextop copy) { // return new Full(copy.context, copy.auth); // } // // private Full(Context context, @Nullable Auth auth) { // super(context, auth); // // } // } private static final class Limited extends GoNoded { static Limited start(Nextop copy) { return new Limited(copy.context, copy.auth); } private static MessageControlNode createLimitedNode() { // FIXME 0.2 cache, durability // head // ^- multi // ^- nextop // ^- http NextopNode nextopNode = new NextopNode(); nextopNode.setWireFactory(new NextopClientWireFactory( new NextopClientWireFactory.Config(Authority.valueOf(/* FIXME move to config */ "dns.nextop.io"), 2))); HttpNode httpNode = new HttpNode(); MultiNode multiNode = new MultiNode(MultiNode.Downstream.create(nextopNode), MultiNode.Downstream.create(httpNode, MultiNode.Downstream.Support.LOCAL)); return multiNode; } private Limited(Context context, @Nullable Auth auth) { super(context, auth, createLimitedNode()); } } /////// TRANSFER STATUS /////// public static final class TransferStatus { public final MessageControlState.TransferProgress send; public final MessageControlState.TransferProgress receive; TransferStatus(MessageControlState.TransferProgress send, MessageControlState.TransferProgress receive) { this.send = send; this.receive = receive; } @Override public String toString() { return String.format("out %s, in %s", send, receive); } } /////// AUTH /////// public static final class Auth { @Nullable static Auth create(@Nullable String accessKey, @Nullable String[] grantKeys) { if (null != accessKey) { Id accessKeyId = Id.valueOf(accessKey); Set<Id> grantKeyIds; if (null != grantKeys) { grantKeyIds = new HashSet<Id>(grantKeys.length); for (String grantKey : grantKeys) { if (null != grantKey) { grantKeyIds.add(Id.valueOf(grantKey)); } } } else { grantKeyIds = Collections.emptySet(); } return create(accessKeyId, grantKeyIds); } else { return null; } } static Auth create(Id accessKeyId, Iterable<Id> grantKeysIds) { if (null == accessKeyId) { throw new IllegalArgumentException(); } if (null == grantKeysIds) { throw new IllegalArgumentException(); } return new Auth(accessKeyId, ImmutableSet.copyOf(grantKeysIds)); } final Id accessKey; final Set<Id> grantKeys; private Auth(Id accessKey, Set<Id> grantKeys) { this.accessKey = accessKey; this.grantKeys = grantKeys; } } }