Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import androidx.mediarouter.media.MediaControlIntent;
import androidx.mediarouter.media.MediaRouteSelector;
import androidx.mediarouter.media.MediaRouter;

import com.google.android.gms.cast.CastMediaControlIntent;
import com.google.android.gms.cast.framework.CastOptions;
Expand Down Expand Up @@ -64,13 +65,38 @@ public CastContextImpl(IObjectWrapper context, CastOptions options, IMediaRouter
String defaultCategory = CastMediaControlIntent.categoryForCast(receiverApplicationId);

this.defaultSessionProvider = this.sessionProviders.get(defaultCategory);
if (this.defaultSessionProvider == null) {
// The provider map can be keyed by the full control category, which carries extra
// namespace/flag suffixes (e.g. ".../CC1AD845///ALLOW_IPV6"), rather than the bare
// categoryForCast(appId). Fall back to matching by category prefix.
for (Map.Entry<String, ISessionProvider> entry : this.sessionProviders.entrySet()) {
if (entry.getKey() != null && entry.getKey().startsWith(defaultCategory)) {
this.defaultSessionProvider = entry.getValue();
break;
}
}
}

// TODO: This should incorporate passed options
this.mergedSelector = new MediaRouteSelector.Builder()
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_VIDEO)
.addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
.addControlCategory(defaultCategory)
.build();

// Observe route selection so that choosing a Cast route actually starts a session.
// This goes through the app's MediaRouterProxy (IMediaRouter), which runs androidx
// MediaRouter in the app process; touching MediaRouter directly from the dynamite would
// fail with a Resources$NotFoundException. On selection the app invokes
// MediaRouterCallbackImpl.onRouteSelected(), which starts the session.
try {
this.router.registerMediaRouterCallbackImpl(this.mergedSelector.asBundle(),
new MediaRouterCallbackImpl(this));
this.router.addCallback(this.mergedSelector.asBundle(),
MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);
} catch (RemoteException e) {
Log.w(TAG, "Failed to register media router callback: " + e.getMessage());
}
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,18 @@ public void onRouteRemoved(String routeId, Bundle extras) {
@Override
public void onRouteSelected(String routeId, Bundle extras) throws RemoteException {
CastDevice castDevice = CastDevice.getFromBundle(extras);

SessionImpl session = (SessionImpl) ObjectWrapper.unwrap(this.castContext.defaultSessionProvider.getSession(null));
Bundle routeInfoExtras = this.castContext.getRouter().getRouteInfoExtrasById(routeId);
if (routeInfoExtras != null) {
session.start(this.castContext, castDevice, routeId, routeInfoExtras);
if (this.castContext.defaultSessionProvider == null) {
Log.w(TAG, "No session provider for selected route " + routeId + "; cannot start session");
return;
}
Object session = ObjectWrapper.unwrap(this.castContext.defaultSessionProvider.getSession(null));
if (!(session instanceof SessionImpl)) {
Log.w(TAG, "Session provider did not yield a SessionImpl for route " + routeId);
return;
}
Bundle routeInfoExtras = this.castContext.getRouter().getRouteInfoExtrasById(routeId);
((SessionImpl) session).start(this.castContext, castDevice, routeId,
routeInfoExtras != null ? routeInfoExtras : extras);
}
@Override
public void unknown(String routeId, Bundle extras) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Parcel;
import android.os.RemoteException;
import android.util.Base64;
Expand Down Expand Up @@ -72,6 +73,16 @@ public class CastDeviceControllerImpl extends ICastDeviceController.Stub impleme

String sessionId = null;

// Fires if the client process dies without a clean disconnect(), so we can tear down the
// CastV2 connection instead of leaking it (and its reader thread) and spamming a dead listener.
private final IBinder.DeathRecipient deathRecipient = new IBinder.DeathRecipient() {
@Override
public void binderDied() {
Log.d(TAG, "Cast client died; disconnecting from device");
disconnect();
}
};

public CastDeviceControllerImpl(Context context, String packageName, Bundle extras) {
this.context = context;
this.packageName = packageName;
Expand Down Expand Up @@ -147,12 +158,12 @@ public void rawMessageReceived(ChromeCastRawMessage message, Long requestId) {
switch (message.getPayloadType()) {
case STRING:
String response = message.getPayloadUtf8();
if (requestId == null) {
this.onTextMessageReceived(message.getNamespace(), response);
} else {
this.onSendMessageSuccess(response, requestId);
this.onTextMessageReceived(message.getNamespace(), response);
}
// Every inbound text message is an application-level message (e.g. MEDIA_STATUS)
// and must be delivered via onTextMessageReceived. Do NOT report it as a send
// success here: onSendMessageSuccess is keyed by the *outgoing* send's requestId
// (signalled in sendMessage), not the response's requestId — conflating them
// completes a non-existent client task and throws a RemoteException.
this.onTextMessageReceived(message.getNamespace(), response);
break;
case BINARY:
byte[] payload = message.getPayloadBinary();
Expand All @@ -161,8 +172,50 @@ public void rawMessageReceived(ChromeCastRawMessage message, Long requestId) {
}
}

@Override
public void connect() {
// Connectionless (cxless) entry point. The classic path connects lazily on first
// launch/sendMessage, but the cxless client blocks until it receives
// onConnectedWithResult, so open the CastV2 channel now and signal readiness.
Log.d(TAG, "connect()");
try {
if (!this.chromecast.isConnected()) {
this.chromecast.connect();
}
this.onConnectedWithResult(CommonStatusCodes.SUCCESS);
} catch (Exception e) {
Log.w(TAG, "Error connecting to chromecast: " + e.getMessage());
this.onConnectedWithResult(CommonStatusCodes.NETWORK_ERROR);
}
}

@Override
public void setListener(ICastDeviceControllerListener listener) {
// cxless delivers the listener here instead of via the GetServiceRequest "listener" extra.
Log.d(TAG, "setListener()");
this.listener = listener;
try {
listener.asBinder().linkToDeath(deathRecipient, 0);
} catch (RemoteException e) {
Log.w(TAG, "Failed to link Cast client death: " + e.getMessage());
}
}

@Override
public void unregisterListener() {
Log.d(TAG, "unregisterListener()");
this.listener = null;
}

@Override
public void disconnect() {
if (this.listener != null) {
try {
this.listener.asBinder().unlinkToDeath(deathRecipient, 0);
} catch (Exception ignored) {
// listener may already be dead or never linked
}
}
try {
this.chromecast.disconnect();
} catch (IOException e) {
Expand All @@ -175,6 +228,10 @@ public void disconnect() {
public void sendMessage(String namespace, String message, long requestId) {
try {
this.chromecast.sendRawRequest(namespace, message, requestId);
// Signal transport-level send success keyed by the outgoing send's requestId so the
// client's sendMessage Task completes; the receiver's reply arrives separately as an
// inbound message via onTextMessageReceived.
this.onSendMessageSuccess("", requestId);
} catch (IOException e) {
Log.w(TAG, "Error sending cast message: " + e.getMessage());
this.onSendMessageFailure("", requestId, CommonStatusCodes.NETWORK_ERROR);
Expand Down Expand Up @@ -325,4 +382,14 @@ public void onDeviceStatusChanged(CastDeviceStatus deviceStatus) {
}
}
}

public void onConnectedWithResult(int statusCode) {
if (this.listener != null) {
try {
this.listener.onConnectedWithResult(statusCode);
} catch (RemoteException ex) {
Log.e(TAG, "Error calling onConnectedWithResult: " + ex.getMessage());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import com.google.android.gms.cast.CastDevice;
import com.google.android.gms.cast.internal.ICastDeviceControllerListener;
import com.google.android.gms.common.internal.ConnectionInfo;
import com.google.android.gms.common.internal.GetServiceRequest;
import com.google.android.gms.common.internal.BinderWrapper;
import com.google.android.gms.common.internal.IGmsCallbacks;
Expand All @@ -45,6 +46,17 @@ public CastDeviceControllerService() {

@Override
public void handleServiceRequest(IGmsCallbacks callback, GetServiceRequest request, GmsService service) throws RemoteException {
callback.onPostInitComplete(0, new CastDeviceControllerImpl(this, request.packageName, request.extras), null);
// Advertise the API features the client requested so its availability check passes
// (otherwise the SDK deems microG too old -> ConnectionResult=2). Echo all of them,
// including the connectionless (cxless) features: CastDeviceControllerImpl now implements
// the connectionless connect/setListener handshake, so the cxless path works.
ConnectionInfo info = new ConnectionInfo();
if (request.apiFeatures != null && request.apiFeatures.length > 0) {
info.features = request.apiFeatures;
} else {
info.features = request.defaultFeatures;
}
callback.onPostInitCompleteWithConnectionInfo(
0, new CastDeviceControllerImpl(this, request.packageName, request.extras), info);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.google.android.gms.cast.internal;

import com.google.android.gms.cast.LaunchOptions;
import com.google.android.gms.cast.JoinOptions;
import com.google.android.gms.cast.internal.ICastDeviceControllerListener;

interface ICastDeviceController {
oneway void disconnect() = 0;
Expand All @@ -11,4 +12,10 @@ interface ICastDeviceController {
oneway void unregisterNamespace(String namespace) = 11;
oneway void launchApplication(String applicationId, in LaunchOptions launchOptions) = 12;
oneway void joinApplication(String applicationId, String sessionId, in JoinOptions joinOptions) = 13;
// Connectionless (Cast.API_CXLESS) path used by the modern Cast SDK: the client delivers its
// listener out-of-band via setListener (txn 18) then calls connect (txn 17), and waits for the
// service to reply ICastDeviceControllerListener.onConnectedWithResult before launching.
oneway void connect() = 16;
oneway void setListener(ICastDeviceControllerListener listener) = 17;
oneway void unregisterListener() = 18;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ interface ICastDeviceControllerListener {
void onSendMessageSuccess(String response, long requestId) = 10;
void onApplicationStatusChanged(in ApplicationStatus applicationStatus) = 11;
void onDeviceStatusChanged(in CastDeviceStatus deviceStatus) = 12;
// Connectionless readiness signal: the cxless client stays "not connected" until the service
// calls this with statusCode 0 (SUCCESS) after connect(). Without it the session never starts.
void onConnectedWithResult(int statusCode) = 13;
}