/* * AirPlayService * Connect SDK * * Copyright (c) 2014 LG Electronics. * Created by Hyun Kook Khang on 18 Apr 2014 * * 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.connectsdk.service; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.util.Log; import com.connectsdk.core.ImageInfo; import com.connectsdk.core.MediaInfo; import com.connectsdk.core.Util; import com.connectsdk.discovery.DiscoveryFilter; import com.connectsdk.etc.helper.DeviceServiceReachability; import com.connectsdk.etc.helper.HttpConnection; import com.connectsdk.etc.helper.HttpMessage; import com.connectsdk.service.airplay.PListBuilder; import com.connectsdk.service.airplay.PListParser; import com.connectsdk.service.capability.CapabilityMethods; import com.connectsdk.service.capability.MediaControl; import com.connectsdk.service.capability.MediaPlayer; import com.connectsdk.service.capability.listeners.ResponseListener; import com.connectsdk.service.command.ServiceCommand; import com.connectsdk.service.command.ServiceCommandError; import com.connectsdk.service.command.ServiceSubscription; import com.connectsdk.service.command.URLServiceSubscription; import com.connectsdk.service.config.ServiceConfig; import com.connectsdk.service.config.ServiceDescription; import com.connectsdk.service.sessions.LaunchSession; import com.connectsdk.service.sessions.LaunchSession.LaunchSessionType; import org.apache.http.protocol.HTTP; import org.json.JSONObject; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.StringTokenizer; import java.util.Timer; import java.util.TimerTask; import java.util.UUID; public class AirPlayService extends DeviceService implements MediaPlayer, MediaControl { public static final String X_APPLE_SESSION_ID = "X-Apple-Session-ID"; public static final String ID = "AirPlay"; public static final String PLAY_STATE = "PlayState"; private static final long KEEP_ALIVE_PERIOD = 15000; private static final long UPDATE_PERIOD = 500; private final static String CHARSET = "UTF-8"; private String mSessionId; private Timer timer, updateTimer; private List<URLServiceSubscription<?>> mSubscriptions = new ArrayList<>(); ServiceCommand pendingCommand = null; String authenticate = null; String password = null; @Override public CapabilityPriorityLevel getPriorityLevel(Class<? extends CapabilityMethods> clazz) { if (clazz.equals(MediaPlayer.class)) { return getMediaPlayerCapabilityLevel(); } else if (clazz.equals(MediaControl.class)) { return getMediaControlCapabilityLevel(); } return CapabilityPriorityLevel.NOT_SUPPORTED; } interface PlaybackPositionListener { void onGetPlaybackPositionSuccess(long duration, long position); void onGetPlaybackPositionFailed(ServiceCommandError error); } public AirPlayService(ServiceDescription serviceDescription, ServiceConfig serviceConfig) throws IOException { super(serviceDescription, serviceConfig); pairingType = PairingType.PIN_CODE; } public static DiscoveryFilter discoveryFilter() { return new DiscoveryFilter(ID, "_airplay._tcp.local."); } @Override public MediaControl getMediaControl() { return this; } @Override public CapabilityPriorityLevel getMediaControlCapabilityLevel() { return CapabilityPriorityLevel.HIGH; } @Override public void play(ResponseListener<Object> listener) { Map <String,String> params = new HashMap<String,String>(); params.put("value", "1.000000"); String uri = getRequestURL("rate", params); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, null, listener); request.send(); } @Override public void pause(ResponseListener<Object> listener) { Map <String,String> params = new HashMap<String,String>(); params.put("value", "0.000000"); String uri = getRequestURL("rate", params); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, null, listener); request.send(); } @Override public void stop(ResponseListener<Object> listener) { String uri = getRequestURL("stop"); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, null, listener); // TODO This is temp fix for issue https://github.com/ConnectSDK/Connect-SDK-Android/issues/66 request.send(); request.send(); stopTimer(); } @Override public void rewind(ResponseListener<Object> listener) { Map <String,String> params = new HashMap<String,String>(); params.put("value", "-2.000000"); String uri = getRequestURL("rate", params); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, null, listener); request.send(); } @Override public void fastForward(ResponseListener<Object> listener) { Map <String,String> params = new HashMap<String,String>(); params.put("value", "2.000000"); String uri = getRequestURL("rate", params); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, null, listener); request.send(); } @Override public void previous(ResponseListener<Object> listener) { Util.postError(listener, ServiceCommandError.notSupported()); } @Override public void next(ResponseListener<Object> listener) { Util.postError(listener, ServiceCommandError.notSupported()); } @Override public void seek(long position, ResponseListener<Object> listener) { float pos = ((float) position / 1000); Map <String,String> params = new HashMap<String,String>(); params.put("position", String.valueOf(pos)); String uri = getRequestURL("scrub", params); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, null, listener); request.send(); } @Override public void getPosition(final PositionListener listener) { getPlaybackPosition(new PlaybackPositionListener() { @Override public void onGetPlaybackPositionSuccess(long duration, long position) { Util.postSuccess(listener, position); } @Override public void onGetPlaybackPositionFailed(ServiceCommandError error) { Util.postError(listener, new ServiceCommandError(0, "Unable to get position", null)); } }); } @Override public void getPlayState(final PlayStateListener listener) { getPlaybackInfo(new ResponseListener<Object>() { @Override public void onSuccess(Object object) { PlayStateStatus playState = PlayStateStatus.Unknown; try { JSONObject response = new PListParser().parse(object.toString()); if (!response.has("rate")) { playState = PlayStateStatus.Finished; } else { int rate = response.getInt("rate"); if (rate == 0) { playState = PlayStateStatus.Paused; } else if (rate == 1) { playState = PlayStateStatus.Playing; } } Util.postSuccess(listener, playState); } catch (Exception e) { Util.postError(listener, new ServiceCommandError(500, e.getMessage(), null)); } } @Override public void onError(ServiceCommandError error) { Util.postError(listener, error); } }); } @Override public void getDuration(final DurationListener listener) { getPlaybackPosition(new PlaybackPositionListener() { @Override public void onGetPlaybackPositionSuccess(long duration, long position) { Util.postSuccess(listener, duration); } @Override public void onGetPlaybackPositionFailed(ServiceCommandError error) { Util.postError(listener, new ServiceCommandError(0, "Unable to get duration", null)); } }); } private void getPlaybackPosition(final PlaybackPositionListener listener) { ResponseListener<Object> responseListener = new ResponseListener<Object>() { @Override public void onSuccess(Object response) { String strResponse = (String) response; long duration = 0; long position = 0; StringTokenizer st = new StringTokenizer(strResponse); while (st.hasMoreTokens()) { String str = st.nextToken(); if (str.contains("duration")) { duration = parseTimeValueFromString(st.nextToken()); } else if (str.contains("position")) { position = parseTimeValueFromString(st.nextToken()); } } if (listener != null) { listener.onGetPlaybackPositionSuccess(duration, position); } } @Override public void onError(ServiceCommandError error) { if (listener != null) listener.onGetPlaybackPositionFailed(error); } }; String uri = getRequestURL("scrub"); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, null, responseListener); request.setHttpMethod(ServiceCommand.TYPE_GET); request.send(); } private long parseTimeValueFromString(String value) { long duration = 0L; try { float f = Float.valueOf(value); duration = (long) f * 1000; } catch (RuntimeException e) { e.printStackTrace(); } return duration; } private void getPlaybackInfo(ResponseListener<Object> listener) { String uri = getRequestURL("playback-info"); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, null, listener); request.setHttpMethod(ServiceCommand.TYPE_GET); request.send(); } @Override public ServiceSubscription<PlayStateListener> subscribePlayState( PlayStateListener listener) { URLServiceSubscription<PlayStateListener> request = new URLServiceSubscription<>(this, PLAY_STATE, null, null); request.addListener(listener); addSubscription(request); return request; } @Override public MediaPlayer getMediaPlayer() { return this; } @Override public CapabilityPriorityLevel getMediaPlayerCapabilityLevel() { return CapabilityPriorityLevel.HIGH; } @Override public void getMediaInfo(MediaInfoListener listener) { Util.postError(listener, ServiceCommandError.notSupported()); } @Override public ServiceSubscription<MediaInfoListener> subscribeMediaInfo( MediaInfoListener listener) { listener.onError(ServiceCommandError.notSupported()); return null; } @Override public void displayImage(final String url, String mimeType, String title, String description, String iconSrc, final LaunchListener listener) { Util.runInBackground(new Runnable() { @Override public void run() { ResponseListener<Object> responseListener = new ResponseListener<Object>() { @Override public void onSuccess(Object response) { LaunchSession launchSession = new LaunchSession(); launchSession.setService(AirPlayService.this); launchSession.setSessionType(LaunchSessionType.Media); Util.postSuccess(listener, new MediaLaunchObject(launchSession, AirPlayService.this)); } @Override public void onError(ServiceCommandError error) { Util.postError(listener, error); } }; String uri = getRequestURL("photo"); byte[] payload = null; try { URL imagePath = new URL(url); HttpURLConnection connection = (HttpURLConnection) imagePath.openConnection(); connection.setInstanceFollowRedirects(true); connection.setDoInput(true); connection.connect(); int responseCode = connection.getResponseCode(); boolean redirect = (responseCode == HttpURLConnection.HTTP_MOVED_TEMP || responseCode == HttpURLConnection.HTTP_MOVED_PERM || responseCode == HttpURLConnection.HTTP_SEE_OTHER); if(redirect) { String newPath = connection.getHeaderField("Location"); URL newImagePath = new URL(newPath); connection = (HttpURLConnection) newImagePath.openConnection(); connection.setInstanceFollowRedirects(true); connection.setDoInput(true); connection.connect(); } InputStream input = connection.getInputStream(); Bitmap myBitmap = BitmapFactory.decodeStream(input); ByteArrayOutputStream stream = new ByteArrayOutputStream(); myBitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream); payload = stream.toByteArray(); } catch (MalformedURLException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(AirPlayService.this, uri, payload, responseListener); request.setHttpMethod(ServiceCommand.TYPE_PUT); request.send(); } }); } @Override public void displayImage(MediaInfo mediaInfo, LaunchListener listener) { String mediaUrl = null; String mimeType = null; String title = null; String desc = null; String iconSrc = null; if (mediaInfo != null) { mediaUrl = mediaInfo.getUrl(); mimeType = mediaInfo.getMimeType(); title = mediaInfo.getTitle(); desc = mediaInfo.getDescription(); if (mediaInfo.getImages() != null && mediaInfo.getImages().size() > 0) { ImageInfo imageInfo = mediaInfo.getImages().get(0); iconSrc = imageInfo.getUrl(); } } displayImage(mediaUrl, mimeType, title, desc, iconSrc, listener); } public void playVideo(final String url, String mimeType, String title, String description, String iconSrc, boolean shouldLoop, final LaunchListener listener) { ResponseListener<Object> responseListener = new ResponseListener<Object>() { @Override public void onSuccess(Object response) { LaunchSession launchSession = new LaunchSession(); launchSession.setService(AirPlayService.this); launchSession.setSessionType(LaunchSessionType.Media); Util.postSuccess(listener, new MediaLaunchObject(launchSession, AirPlayService.this)); startTimer(); } @Override public void onError(ServiceCommandError error) { Util.postError(listener, error); } }; String uri = getRequestURL("play"); String payload = null; PListBuilder builder = new PListBuilder(); builder.putString("Content-Location", url); builder.putReal("Start-Position", 0); payload = builder.toString(); ServiceCommand<ResponseListener<Object>> request = new ServiceCommand<ResponseListener<Object>>(this, uri, payload, responseListener); request.send(); } @Override public void playMedia(String url, String mimeType, String title, String description, String iconSrc, boolean shouldLoop, LaunchListener listener) { if (mimeType.contains("image")) { displayImage(url, mimeType, title, description, iconSrc, listener); } else { playVideo(url, mimeType, title, description, iconSrc, shouldLoop, listener); } } @Override public void playMedia(MediaInfo mediaInfo, boolean shouldLoop, LaunchListener listener) { String mediaUrl = null; String mimeType = null; String title = null; String desc = null; String iconSrc = null; if (mediaInfo != null) { mediaUrl = mediaInfo.getUrl(); mimeType = mediaInfo.getMimeType(); title = mediaInfo.getTitle(); desc = mediaInfo.getDescription(); if (mediaInfo.getImages() != null && mediaInfo.getImages().size() > 0) { ImageInfo imageInfo = mediaInfo.getImages().get(0); iconSrc = imageInfo.getUrl(); } } playMedia(mediaUrl, mimeType, title, desc, iconSrc, shouldLoop, listener); } @Override public void closeMedia(LaunchSession launchSession, ResponseListener<Object> listener) { stop(listener); } @Override public void sendCommand(final ServiceCommand<?> serviceCommand) { Util.runInBackground(new Runnable() { @Override public void run() { try { StringBuilder sb = new StringBuilder(); sb.append("http://").append(serviceDescription.getIpAddress()).append(":").append(serviceDescription.getPort()); sb.append(serviceCommand.getTarget()); HttpConnection connection = HttpConnection.newInstance(URI.create(sb.toString())); connection.setHeader(HTTP.USER_AGENT, "ConnectSDK MediaControl/1.0"); connection.setHeader(X_APPLE_SESSION_ID, mSessionId); if (password != null) { String authorization = getAuthenticate(serviceCommand.getHttpMethod(), serviceCommand.getTarget(), authenticate); connection.setHeader("Authorization", authorization); } Object payload = serviceCommand.getPayload(); if (serviceCommand.getHttpMethod().equalsIgnoreCase(ServiceCommand.TYPE_POST) || serviceCommand.getHttpMethod().equalsIgnoreCase(ServiceCommand.TYPE_PUT)) { if (payload != null) { if (payload instanceof String) { connection.setHeader(HttpMessage.CONTENT_TYPE_HEADER, HttpMessage.CONTENT_TYPE_APPLICATION_PLIST); connection.setPayload(payload.toString()); } else if (payload instanceof byte[]) { connection.setPayload((byte[]) payload); } } } if (serviceCommand.getHttpMethod().equalsIgnoreCase(ServiceCommand.TYPE_POST)) { connection.setMethod(HttpConnection.Method.POST); } else if (serviceCommand.getHttpMethod().equalsIgnoreCase(ServiceCommand.TYPE_PUT)) { connection.setMethod(HttpConnection.Method.PUT); } else { connection.setHeader("Content-Length", "0"); } connection.execute(); int code = connection.getResponseCode(); if (code == HttpURLConnection.HTTP_OK) { Util.postSuccess(serviceCommand.getResponseListener(), connection.getResponseString()); } else if (code == HttpURLConnection.HTTP_UNAUTHORIZED) { authenticate = connection.getResponseHeader("WWW-Authenticate"); pendingCommand = serviceCommand; Util.runOnUI(new Runnable() { @Override public void run() { if (listener != null) { listener.onPairingRequired(AirPlayService.this, pairingType, null); } } }); } else { Util.postError(serviceCommand.getResponseListener(), ServiceCommandError.getError(code)); } } catch (IOException e) { e.printStackTrace(); Util.postError(serviceCommand.getResponseListener(), new ServiceCommandError(0, e.getMessage(), null)); } } }); } @Override public void sendPairingKey(String pairingKey) { password = pairingKey; if (pendingCommand != null) pendingCommand.send(); pendingCommand = null; } String getAuthenticate(String method, String digestURI, String authStr) { String realm = null; String nonce = null; StringTokenizer st = new StringTokenizer(authStr, "=\", "); while (st.hasMoreTokens()) { String str = st.nextToken(); if (str.equalsIgnoreCase("realm")) { // Digest realm realm = st.nextToken(); } else if (str.equalsIgnoreCase("nonce")) { nonce = st.nextToken(); } } StringBuilder sb = new StringBuilder(); sb.append("AirPlay").append(":").append(realm).append(":").append(password); String HA1 = digestAuthentication(sb.toString()); sb = new StringBuilder(); sb.append(method).append(":").append(digestURI); String HA2 = digestAuthentication(sb.toString()); sb = new StringBuilder(); sb.append(HA1).append(":").append(nonce).append(":").append(HA2); String response = digestAuthentication(sb.toString()); sb = new StringBuilder(); sb.append("Digest username").append("=").append("\"").append("AirPlay").append("\"").append(", "); sb.append("realm").append("=").append("\"").append(realm).append("\"").append(", "); sb.append("nonce").append("=").append("\"").append(nonce).append("\"").append(", "); sb.append("uri").append("=").append("\"").append(digestURI).append("\"").append(", "); sb.append("response").append("=").append("\"").append(response).append("\""); return sb.toString(); } String digestAuthentication(String md5) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(md5.getBytes()); StringBuffer sb = new StringBuffer(); for (byte b : digest) { sb.append(String.format("%02x", b & 0xFF)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (NullPointerException e) { e.printStackTrace(); } return null; } @Override protected void updateCapabilities() { List<String> capabilities = new ArrayList<String>(); capabilities.add(Display_Image); capabilities.add(Play_Video); capabilities.add(Play_Audio); capabilities.add(Close); capabilities.add(Play); capabilities.add(Pause); capabilities.add(Stop); capabilities.add(Position); capabilities.add(Duration); capabilities.add(PlayState); capabilities.add(Seek); capabilities.add(Rewind); capabilities.add(FastForward); capabilities.add(PlayState_Subscribe); setCapabilities(capabilities); } private String getRequestURL(String command) { return getRequestURL(command, null); } private String getRequestURL(String command, Map<String, String> params) { StringBuilder sb = new StringBuilder(); sb.append("/").append(command); if (params != null) { for (Map.Entry<String, String> entry : params.entrySet()) { String param = String.format("?%s=%s", entry.getKey(), entry.getValue()); sb.append(param); } } return sb.toString(); } @Override public boolean isConnectable() { return true; } @Override public boolean isConnected() { return connected; } @Override public void connect() { mSessionId = UUID.randomUUID().toString(); getPlaybackInfo(new ResponseListener<Object>() { @Override public void onSuccess(Object object) { connected = true; reportConnected(true); } @Override public void onError(ServiceCommandError error) { if (listener != null) { listener.onConnectionFailure(AirPlayService.this, error); } } }); } @Override public void disconnect() { stopTimer(); connected=false; password = null; if (mServiceReachability != null) mServiceReachability.stop(); Util.runOnUI(new Runnable() { @Override public void run() { if (listener != null) listener.onDisconnect(AirPlayService.this, null); } }); } @Override public void onLoseReachability(DeviceServiceReachability reachability) { if (connected) { disconnect(); } else { mServiceReachability.stop(); } } /** * We send periodically a command to keep connection alive and for avoiding * stopping media session * * Fix for https://github.com/ConnectSDK/Connect-SDK-Cordova-Plugin/issues/5 */ private void startTimer() { stopTimer(); timer = new Timer(); timer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { Log.d("Timer", "Timer"); getPlaybackPosition(new PlaybackPositionListener() { @Override public void onGetPlaybackPositionSuccess(long duration, long position) { if (position >= duration) { stopTimer(); } } @Override public void onGetPlaybackPositionFailed(ServiceCommandError error) { } }); } }, KEEP_ALIVE_PERIOD, KEEP_ALIVE_PERIOD); updateTimer = new Timer(); updateTimer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { getPlaybackInfo(new ResponseListener<Object>() { @Override public void onSuccess(Object object) { PlayStateStatus playState = PlayStateStatus.Unknown; try { JSONObject response = new PListParser().parse(object.toString()); if (response.length() > 0) { boolean readyToPlay = false; if (response.has("readyToPlay")) { readyToPlay = response.getBoolean("readyToPlay"); } if (!readyToPlay) { playState = PlayStateStatus.Buffering; } else { if (!response.has("rate")) { playState = PlayStateStatus.Finished; } else { int rate = response.getInt("rate"); if (rate == 0) { playState = PlayStateStatus.Paused; } else if (rate == 1) { playState = PlayStateStatus.Playing; } } } if (mSubscriptions.size() > 0) { for (URLServiceSubscription<?> subscription : mSubscriptions) { if (subscription.getTarget().equalsIgnoreCase(PLAY_STATE)) { for (int i = 0; i < subscription.getListeners().size(); i++) { @SuppressWarnings("unchecked") ResponseListener<Object> listener = (ResponseListener<Object>) subscription.getListeners().get(i); Util.postSuccess(listener, playState); } } } } } } catch (Exception e) { } } @Override public void onError(ServiceCommandError error) { } }); } }, 0, UPDATE_PERIOD); } private void stopTimer() { if (timer != null) { timer.cancel(); } if(updateTimer != null) { updateTimer.cancel(); } updateTimer = null; timer = null; } private void addSubscription(URLServiceSubscription<?> subscription) { mSubscriptions.add(subscription); } @Override public void unsubscribe(URLServiceSubscription<?> subscription) { mSubscriptions.remove(subscription); } public List<URLServiceSubscription<?>> getSubscriptions() { return mSubscriptions; } public void setSubscriptions(List<URLServiceSubscription<?>> subscriptions) { this.mSubscriptions = subscriptions; } }