In a cloud phone setup, the cloud virtual device controls screen orientation — not the local device's sensors. When the cloud rotates from portrait to landscape, the local client must follow in sync. Without this synchronization, the video stream appears cropped, stretched, or rotated incorrectly, and touch input coordinates no longer map to the correct screen regions.
This topic explains how to implement cloud-driven screen rotation on Android and iOS using the ECP SDK.
How it works
Synchronizing screen rotation requires handling two independent concerns:
UI orientation — forcing the local Activity (Android) or ViewController (iOS) to switch orientation to match the cloud device
Video frame rotation — adjusting the rendering layer so the video stream fills the screen correctly without distortion
Both must happen together. If you update the UI orientation without rotating the video frame (or vice versa), you get a misaligned or distorted display.
The cloud sends a rotation value over a data channel named wy_vdagent_default_dc. The local client receives it, switches the UI orientation, and rotates the rendering layer accordingly.
| Rotation value | Orientation |
|---|---|
0 | Portrait |
1 | Landscape left |
3 | Landscape right |
Commands are parsed using CommandUtils.parseCommand(), which triggers an onRotation callback with the rotation value.
Prerequisites
Before you begin, ensure that you have:
The ECP Android or iOS SDK integrated into your project
A cloud phone instance running and reachable from your local client
Handle local rotation on Android
The Android implementation uses TextureView for rendering and a DataChannel to receive rotation commands from the cloud.
Step 1: Configure the StreamView
Disable automatic cloud orientation control and switch the rendering view to TextureView:
// Disable cloud-side automatic orientation control
bundle.putBoolean(StreamView.CONFIG_DISABLE_ORIENTATION_CLOUD_CONTROL, true);
// Use TextureView instead of SurfaceView for rotation support
mStreamView = findViewById(R.id.stream_view);
mStreamView.enableTextureView(true);Step 2: Receive rotation commands and sync orientation
Register a DataChannel listener on wy_vdagent_default_dc. When onRotation fires, the handler performs two actions on the UI thread:
Step 2a — Rotate the video frame: Call
setSurfaceRotation()to rotate the underlying texture to match the cloud orientation.Step 2b — Lock the Activity orientation: Call
setRequestedOrientation()to prevent sensor-driven rotation from overriding the cloud state.
mStreamView.getASPEngineDelegate().addDataChannel(new DataChannel("wy_vdagent_default_dc") {
@Override
protected void onReceiveData(byte[] buf) {
String str = "";
try {
str = new String(buf, "UTF-8");
} catch (UnsupportedEncodingException e) {
str = new String(buf);
}
Log.i(TAG, "wy_vdagent_default_dc dc received " + buf.length + " bytes data:" + str);
CommandUtils.parseCommand(str, new CommandUtils.CommandListener() {
@Override
public void onCameraAuthorize() {
checkStartCpd();
}
@Override
public void onRotation(int rotation) {
runOnUiThread(() -> {
// Step 2a: Rotate the video texture to match the cloud orientation
mStreamView.setSurfaceRotation(rotation);
// Step 2b: Lock the Activity orientation to prevent sensor-driven rotation
if (rotation == 1) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
} else if (rotation == 3) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE);
} else {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
});
}
@Override
public void onUnknownCommand(String cmd) {
showError("Unknown command: " + cmd);
}
});
}
@Override
protected void onConnectStateChanged(DataChannelConnectState state) {
Log.i(TAG, "wy_vdagent_default_dc dc connection state changed to " + state);
}
});Both setSurfaceRotation() and setRequestedOrientation() run on the UI thread via runOnUiThread().
Handle local rotation on iOS
The iOS implementation splits the work across two classes: BaseViewController manages orientation state, and DemoEDSAgentChannel receives cloud commands and applies the rotation transform to StreamView.
Step 1: Configure supported orientations in Info.plist
In Info.plist, declare support for portrait, landscape left, and landscape right:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</plist>Step 2: Implement BaseViewController
BaseViewController holds the current rotation state and forces the orientation switch when the cloud sends a new value. Setting shouldAutorotate to false disables sensor-driven rotation — only cloud commands drive orientation changes.
@implementation BaseViewController {
NSInteger mRoration;
}
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
}
- (void)switchRoration:(NSInteger)roration {
mRoration = roration;
// Force orientation switch (recommended for iOS 16+)
if (@available(iOS 16.0, *)) {
[self setNeedsUpdateOfSupportedInterfaceOrientations];
} else {
// Legacy method (deprecated, but compatible with iOS 15 and earlier)
NSNumber *value = @([self supportedInterfaceOrientations]);
[[UIDevice currentDevice] setValue:value forKey:@"orientation"];
}
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
switch (mRoration) {
case 1:
return UIInterfaceOrientationMaskLandscapeLeft;
case 3:
return UIInterfaceOrientationMaskLandscapeRight;
default:
return UIInterfaceOrientationMaskPortrait;
}
}
- (BOOL)shouldAutorotate {
return false; // Disable sensor-driven rotation; only cloud commands control orientation
}
// Optional: Specify the preferred orientation for presentation
- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation {
switch (mRoration) {
case 1: return UIInterfaceOrientationLandscapeLeft;
case 3: return UIInterfaceOrientationLandscapeRight;
default: return UIInterfaceOrientationPortrait;
}
}
@endStep 3: Set up the data channel
In your view controller, initialize DemoEDSAgentChannel and add it to the StreamView:
self.esdAgent = [[DemoEDSAgentChannel alloc] initWithParameter:DATA_CHANNEL_NAME];
self.esdAgent.streamView = self.streamView;
self.esdAgent.viewController = self;
[self.streamView addDataChannel:self.esdAgent];Step 4: Handle rotation commands in DemoEDSAgentChannel
DemoEDSAgentChannel decodes incoming data, parses the rotation command, and applies two changes on the main thread:
Step 4a — Switch UI orientation: Call
switchRoration:on the view controller.Step 4b — Rotate the video frame: Apply a
CGAffineTransformto theStreamView.
For landscape orientations, the StreamView center coordinates are swapped (X and Y) to keep the video centered after the 90-degree transform.
@interface DemoEDSAgentChannel() <CommandListener>
@property (nonatomic, assign) CGRect rect;
@end
@implementation DemoEDSAgentChannel
- (void)setViewController:(BaseViewController *)viewController {
self.rect = viewController.view.bounds;
_viewController = viewController;
}
- (void)onConnectStateChanged:(ASPDCConnectState)state {
NSLog(@"[DemoEDSAgentChannel] onConnectStateChanged %ld", state);
if (state == OPEN) {
// to send data
}
}
- (void)onReceiveData:(NSData * _Nonnull)buf {
NSString *string = [[NSString alloc] initWithData:buf encoding:NSUTF8StringEncoding];
NSLog(@"[DemoEDSAgentChannel] onConnectStateChanged %@", string);
[CommandUtils parseCommand:string listener:self];
}
#pragma mark - CommandListener
- (void)onRotation:(NSInteger)value {
NSLog(@"[DemoEDSAgentChannel] onRotation %ld", value);
dispatch_async(dispatch_get_main_queue(), ^{
// Step 4a: Switch UI orientation
[self.viewController switchRoration:value];
// Step 4b: Rotate the StreamView to match the video frame orientation
if (value == 1 || value == 3) {
// Swap X and Y center coordinates to keep the view centered in landscape
self.streamView.center = CGPointMake(CGRectGetMidY(self.viewController.view.bounds),
CGRectGetMidX(self.viewController.view.bounds));
self.streamView.transform = CGAffineTransformMakeRotation(-M_PI_2 * value);
} else {
self.streamView.center = CGPointMake(CGRectGetMidX(self.rect),
CGRectGetMidY(self.rect));
self.streamView.transform = CGAffineTransformIdentity;
}
});
}
@end