/*
* Copyright (C) 2016 Jorge Ruesga
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ruesga.android.wallpapers.photophase.cast;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;
import android.os.PowerManager;
import android.text.TextUtils;
import android.util.Log;
import com.ruesga.android.wallpapers.photophase.R;
import com.ruesga.android.wallpapers.photophase.cast.CastDeviceMessages.BaseDeviceMessage;
import com.ruesga.android.wallpapers.photophase.cast.CastDeviceMessages.OnNewTrackMessage;
import com.ruesga.android.wallpapers.photophase.cast.CastDeviceMessages.OnReadyMessage;
import com.ruesga.android.wallpapers.photophase.cast.CastService.CastStatusInfo;
import com.ruesga.android.wallpapers.photophase.preferences.PreferencesProvider;
import com.ruesga.android.wallpapers.photophase.utils.BitmapUtils;
import org.codehaus.jackson.map.ObjectMapper;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import javax.net.ssl.SSLServerSocketFactory;
import fi.iki.elonen.NanoHTTPD;
import su.litvak.chromecast.api.v2.AppEvent;
import su.litvak.chromecast.api.v2.ChromeCast;
import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEvent;
import su.litvak.chromecast.api.v2.ChromeCastSpontaneousEventListener;
import su.litvak.chromecast.api.v2.Close;
import su.litvak.chromecast.api.v2.Status;
public class CastServer extends NanoHTTPD {
private static final String TAG = "CastServer";
private final static String PHOTOPHASE_APP_ID = "80F2080C";
private final static String PHOTOPHASE_NAMESPACE =
"urn:x-cast:com.ruesga.android.wallpapers.photophase";
private final static int VERSION = 1;
private static final String HASH_KEY = "k";
private static final ObjectMapper MAPPER = new ObjectMapper();
public interface CastServerEventListener {
void onCastServerDisconnected();
void onNewTrackReceived(String sender);
CastStatusInfo obtainCastStatusInfo();
}
private final Context mContext;
private PowerManager.WakeLock mCpuWakeLock;
private final ChromeCast mChromecast;
private boolean mIsSecure;
private boolean mIsConnected;
private final Map<String, String> mRequests = new HashMap<>();
private final Object mLock = new Object();
private boolean mCastReady;
private boolean mCastReceiverNeedsUpgrade;
private File mCurrentlyPlaying;
private CastStatusInfo mLastStatus;
private int mTimeout;
private boolean mDaemon;
private final CastServerEventListener mCastServerEventListener;
private final ChromeCastSpontaneousEventListener mCastEventListener
= new ChromeCastSpontaneousEventListener() {
@Override
public void spontaneousEventReceived(ChromeCastSpontaneousEvent evt) {
Object data = evt.getData();
if (data instanceof AppEvent) {
AppEvent appEvt = (AppEvent) data;
if (appEvt.namespace.equals(PHOTOPHASE_NAMESPACE)) {
Log.d(TAG, "Received app event: " + appEvt.message);
try {
BaseDeviceMessage msg = CastDeviceMessages.parseDeviceMessage(appEvt.message);
if (msg instanceof OnReadyMessage) {
OnReadyMessage message = (OnReadyMessage) msg;
synchronized (mLock) {
mCastReady = true;
mCastReceiverNeedsUpgrade = message.mVersion < VERSION;
mLock.notify();
}
} else if (msg instanceof OnNewTrackMessage) {
OnNewTrackMessage message = (OnNewTrackMessage) msg;
String media = mRequests.remove(message.mToken);
CastStatusInfo status = mLastStatus != null
? mLastStatus : mCastServerEventListener.obtainCastStatusInfo();
if (status != null && media != null) {
File f = new File(media);
CastNotification.showNotification(mContext, f, status);
setCurrentlyPlaying(f, false, status);
// Notify
mCastServerEventListener.onNewTrackReceived(message.mSender);
}
}
} catch (Exception ex) {
// Ignore
Log.e(TAG, "Can't parse spontaneous message: " + appEvt.message, ex);
}
}
} else if (data instanceof Close) {
Close ev = (Close) data;
if (!ev.requestedBySender) {
stop();
mCastServerEventListener.onCastServerDisconnected();
}
}
}
};
public CastServer(Context context, ChromeCast chromecast, CastServerEventListener listener) {
super(resolveCastServerAddress(chromecast), 0);
mContext = context;
mChromecast = chromecast;
mCastServerEventListener = listener;
PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE);
mCpuWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "photophase_server");
mCpuWakeLock.setReferenceCounted(false);
}
public final String getServerBaseUrl() {
return (mIsSecure ? "https" : "http") + "://" + getHostname() + ":" + getListeningPort();
}
public ChromeCast getChromecast() {
return mChromecast;
}
public File getCurrentlyPlaying() {
return mCurrentlyPlaying;
}
protected void onCastStatusUpdated(CastStatusInfo status) {
mLastStatus = status;
CastNotification.showNotification(mContext, mCurrentlyPlaying, status);
}
private String createAuthorizedUrl(String token) {
return getServerBaseUrl() + "/?" + HASH_KEY + "=" + token;
}
@Override
public void start(int timeout, boolean daemon) throws IOException {
// Connect to the ChromeCast and start serving media
synchronized (mLock) {
mCastReceiverNeedsUpgrade = true;
}
int retry = 0;
while (mCastReceiverNeedsUpgrade) {
try {
mChromecast.registerListener(mCastEventListener);
mChromecast.connect();
// Launch the app
Status status = mChromecast.getStatus();
if (!status.isAppRunning(PHOTOPHASE_APP_ID)) {
mChromecast.launchApp(PHOTOPHASE_APP_ID);
}
// And now configure the app and wait for ready event
sendConfiguration();
synchronized (mLock) {
if (!mCastReady) {
try {
mLock.wait(5000L);
} catch (InterruptedException e) {
// Ignore
}
}
}
} catch (Exception e) {
throw new IOException(e);
}
// Check that app is ready
if (!mCastReady) {
throw new IOException("Cast app not ready.");
}
// Check if we are running a valid version of the client receiver
if (retry == 0 && mCastReceiverNeedsUpgrade) {
Status status = mChromecast.getStatus();
if (status.isAppRunning(PHOTOPHASE_APP_ID)) {
mChromecast.stopApp();
}
}
retry++;
}
// Start the server
try {
mTimeout = timeout;
mDaemon = daemon;
super.start(timeout, daemon);
} catch (IOException ex) {
stop();
throw ex;
}
Log.d(TAG, "Cast server connected and running at " + getServerBaseUrl());
mIsConnected = true;
}
protected boolean send(String path) throws IOException {
File f = new File(path);
if (!f.isFile()) {
throw new IOException("Argument is not a file");
}
// Get bitmap dimensions
Rect r = BitmapUtils.getBitmapDimensions(f);
if (r == null) {
throw new IOException("File not exists " + f.getAbsolutePath());
}
// Relaunch the app
if (!safelyCheckIfAppIsRunning()) {
mChromecast.launchApp(PHOTOPHASE_APP_ID);
}
// Notify the user that we are interacting with the device
CastNotification.showNotification(mContext, mCurrentlyPlaying, new CastStatusInfo());
// Authorize the request
String token = UUID.randomUUID().toString();
mRequests.put(token, path);
String url = createAuthorizedUrl(token);
String name = CastUtils.getTrackName(f);
String album = CastUtils.getAlbumName(f);
CastMessages.Cast cast = new CastMessages.Cast(url, token, name, album, r.width(), r.height());
printRequestMessage(cast);
mChromecast.send(PHOTOPHASE_NAMESPACE, cast);
return true;
}
protected void sendConfiguration() throws IOException {
CastMessages.Configuration config = new CastMessages.Configuration();
config.mName = mContext.getString(R.string.app_name);
config.mLabel = mContext.getString(R.string.cast_app_description);
config.mDeviceName = mChromecast.getName();
config.mIcon = CastUtils.getIconResource();
config.mShowTime = PreferencesProvider.Preferences.Cast.isShowTime(mContext);
config.mShowWeather = PreferencesProvider.Preferences.Cast.isShowWeather(mContext);
config.mShowLogo = PreferencesProvider.Preferences.Cast.isShowLogo(mContext);
config.mShowTrack = PreferencesProvider.Preferences.Cast.isShowTrack(mContext);
config.mCropCenter = !PreferencesProvider.Preferences.Cast.isKeepAspectRatio(mContext);
config.mBlurBackground = PreferencesProvider.Preferences.Cast.isBlurredBackground(mContext);
config.mLoadingMsg = mContext.getString(R.string.cast_loading_msg);
printRequestMessage(config);
if (safelyCheckIfAppIsRunning()) {
mChromecast.send(PHOTOPHASE_NAMESPACE, config);
}
}
protected void sendStopCast() throws IOException {
CastMessages.Stop stop = new CastMessages.Stop();
if (safelyCheckIfAppIsRunning()) {
mChromecast.send(PHOTOPHASE_NAMESPACE, stop);
}
setCurrentlyPlaying(null, false, null);
}
@Override
public void stop() {
if (!mIsConnected) {
return;
}
// Stop the chromecast
try {
if (mChromecast.isAppRunning(PHOTOPHASE_APP_ID)) {
mChromecast.stopApp();
}
} catch (Exception ex) {
// Ignore
}
try {
mChromecast.unregisterListener(mCastEventListener);
mChromecast.disconnect();
} catch (IOException ex) {
// Ignore
}
// Stop the server
super.stop();
mIsConnected = false;
setCurrentlyPlaying(null, true, null);
}
private void reconnect() throws IOException {
// Restart the server
stop();
start(mTimeout, mDaemon);
}
@Override
public void makeSecure(SSLServerSocketFactory sslServerSocketFactory, String[] sslProtocols) {
super.makeSecure(sslServerSocketFactory, sslProtocols);
mIsSecure = true;
}
@Override
@SuppressWarnings("ConstantConditions")
public synchronized Response serve(IHTTPSession session) {
// Acquire a wakelock while serving the file
mCpuWakeLock.acquire();
// We only allow request coming from the ChromeCast device we bound to
if (!isAuthorized(session.getRemoteIpAddress())) {
return createFailureResponse(Response.Status.UNAUTHORIZED);
}
// Check we received a valid request before serve it
Map<String, String> params = session.getParms();
if (!params.containsKey(HASH_KEY)) {
return createFailureResponse(Response.Status.BAD_REQUEST);
}
if (TextUtils.isEmpty(params.get(HASH_KEY))) {
return createFailureResponse(Response.Status.BAD_REQUEST);
}
String hash = params.get(HASH_KEY);
if (!mRequests.containsKey(hash)) {
return createFailureResponse(Response.Status.FORBIDDEN);
}
File f = new File(mRequests.get(hash));
String mimeType = CastUtils.getTrackMimeType(f);
if (TextUtils.isEmpty(mimeType)) {
return createFailureResponse(Response.Status.FORBIDDEN);
}
// Full quality or compressed?
try {
if (PreferencesProvider.Preferences.Cast.isFullQuality(mContext)) {
// Full quality
Log.d(TAG, "Sent success response " + f);
return newChunkedResponse(
Response.Status.OK,
mimeType,
new BufferedInputStream(new FileInputStream(f), 4096));
} else {
// Compress to webp format
long start = System.currentTimeMillis();
Rect r = BitmapUtils.getBitmapDimensions(f);
BitmapUtils.adjustRectToMinimumSize(r, BitmapUtils.calculateMaxAvailableSize(mContext));
Bitmap src = BitmapUtils.createUnscaledBitmap(f, r.width(), r.height());
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
src.compress(Bitmap.CompressFormat.WEBP, 60, out);
long end = System.currentTimeMillis();
Log.d(TAG, "Compressed " + f + " to webp in " + (end - start) + "ms"
+ "; dimensions " + src.getWidth() + "x" + src.getHeight()
+ "; size: " + out.size() + "; original: " + BitmapUtils.byteSizeOf(src));
Log.d(TAG, "Sent success response" + f);
return newChunkedResponse(
Response.Status.OK,
mimeType,
new BufferedInputStream(
new ByteArrayInputStream(out.toByteArray()), 4096));
} finally {
src.recycle();
}
}
} catch (Exception ex) {
Log.e(TAG, "Failed to response request: " + f, ex);
return createFailureResponse(Response.Status.INTERNAL_ERROR);
} finally {
// Release the wakelock
if (mCpuWakeLock.isHeld()) {
mCpuWakeLock.release();
}
}
}
private boolean isAuthorized(String remote) {
return mChromecast.getAddress().equalsIgnoreCase(remote);
}
private static String resolveCastServerAddress(ChromeCast chromecast) {
try {
Enumeration<NetworkInterface> e =
NetworkInterface.getNetworkInterfaces();
while (e.hasMoreElements()) {
NetworkInterface ni = e.nextElement();
for (InterfaceAddress ia : ni.getInterfaceAddresses()) {
String ipMask = ia.getAddress().getHostAddress()
+ "/" + ia.getNetworkPrefixLength();
CIDRUtils cidr = new CIDRUtils(ipMask);
if (cidr.isInRange(chromecast.getAddress())) {
return ia.getAddress().getHostAddress();
}
}
}
} catch (IOException ex) {
// Ignore
}
return null;
}
private Response createFailureResponse(Response.Status status) {
return newFixedLengthResponse(status, NanoHTTPD.MIME_HTML,
"<html><body><b>" + status.getDescription() + "</b></body></html>");
}
private void printRequestMessage(CastMessages.BaseMessage request) {
try {
Log.d(TAG, "Cast Request Message: " + MAPPER.writeValueAsString(request));
} catch (IOException e) {
// Ignore
}
}
private void setCurrentlyPlaying(File media, boolean hide, CastStatusInfo status) {
mCurrentlyPlaying = media;
mLastStatus = status;
if (hide) {
CastNotification.hideNotification(mContext);
mLastStatus = null;
}
}
private boolean safelyCheckIfAppIsRunning() {
try {
return mChromecast.isAppRunning(PHOTOPHASE_APP_ID);
} catch (Exception ex) {
// Ignore
}
// If we lost connectivity
if (CastUtils.testConnectivity(mChromecast)) {
try {
mChromecast.disconnect();
} catch (Exception ex) {
// Ignore
}
try {
mChromecast.connect();
} catch (Exception ex) {
return false;
}
try {
reconnect();
} catch (Exception ex) {
return false;
}
try {
return mChromecast.isAppRunning(PHOTOPHASE_APP_ID);
} catch (Exception ex) {
return false;
}
}
return false;
}
}