Skip to content

Commit 42b6280

Browse files
quanruclaude
andauthored
feat(ios): implement proper device pixel ratio detection (#1257)
* feat(ios): implement proper device pixel ratio detection Replace hardcoded scale=1 with real device pixel ratio from WebDriverAgent API. - Add getScreenScale() method to IOSWebDriverClient using /wda/screen endpoint - Add getDevicePixelRatio() method to IOSDevice for consistent DPR detection - Update getScreenSize() to use real scale instead of hardcoded value - Improve fallback values from scale 1 to scale 2 (more typical for iOS) - Add iOS-specific terms to dictionary for spell check This aligns iOS implementation with Android's approach of getting real DPR from system APIs, ensuring accurate coordinate calculations for different device densities. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * refactor(ios): streamline device pixel ratio retrieval and error handling * perf(ios): cache device pixel ratio to avoid repeated API calls Optimize device pixel ratio handling by: - Add devicePixelRatioInitialized flag to cache the value - Implement initializeDevicePixelRatio() method for one-time initialization - Call initialization in getScreenSize() to ensure it's cached - Update description to use the correct scale value This prevents repeated calls to WebDriverAgent /wda/screen endpoint and improves performance, following the same pattern as Android implementation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * perf(android): cache device pixel ratio to avoid repeated ADB calls Optimize Android device pixel ratio handling by: - Add devicePixelRatioInitialized flag to cache the DPR value - Implement initializeDevicePixelRatio() method for one-time initialization - Call initialization in size() method to ensure it's cached - Remove repeated getDisplayDensity() calls from size() method This prevents expensive ADB shell commands (dumpsys display) from being executed on every size() call, significantly improving performance. The getDisplayDensity() method involves complex regex parsing and multiple shell commands, making caching essential for good performance. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * fix(ios,android): throw error when device pixel ratio detection fails Remove safe default values and throw errors when DPR detection fails to: - Make device configuration issues visible immediately - Prevent silent failures that could lead to coordinate calculation errors - Ensure proper device setup before automation begins Changes: - iOS: Remove fallback to scale=2, throw error if WebDriverAgent API fails - Android: Remove try-catch wrapper in initializeDevicePixelRatio() - Both platforms now fail fast when DPR cannot be determined This ensures coordinate calculations are always based on accurate device information rather than potentially incorrect default values. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * refactor(android): remove unnecessary coordinate conversion and image resizing Remove redundant operations that were converting between physical and logical pixels: - Remove reverseAdjustCoordinates() method and its usage in size() - Return physical pixel dimensions directly from size() method - Remove resizeAndConvertImgBuffer() from screenshotBase64() - Remove unnecessary width/height parameters from screenshot method - Remove unused import resizeAndConvertImgBuffer The Android screenshot is already in physical pixels, and coordinate conversion is handled by adjustCoordinates() when needed for touch operations. This simplifies the code and avoids double conversions that could introduce errors. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * fix(android): restore logical pixel coordinate system for accurate touch Fix coordinate system to ensure accurate touch operations: - Convert physical pixels to logical pixels in size() method - Return logical dimensions that users expect (width/dpr, height/dpr) - adjustCoordinates() converts logical coordinates to physical pixels for touch - This maintains the expected coordinate system where user coordinates match screen dimensions Without this fix, touch coordinates were being double-scaled since size() returned physical pixels but adjustCoordinates() was still scaling them up. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * fix(playground): update NavActions props to disable tooltip and model name display * test(ios): update unit tests for new device pixel ratio implementation Update iOS device tests to match the new DPR detection implementation: - Add getScreenScale mock method returning scale=2 - Update expected DPR from 1 to 2 in all size() assertions - Add getScreenScale mock to additional test setups - Update test comments to reflect new DPR source Tests now correctly validate that device pixel ratio is obtained from WebDriverAgent's /wda/screen endpoint rather than being hardcoded. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * fix(ios): simplify device pixel ratio initialization and error handling * fix(ios): replace error throwing with assertions for element location and device state --------- Co-authored-by: Claude <[email protected]>
1 parent acd86ca commit 42b6280

File tree

6 files changed

+93
-70
lines changed

6 files changed

+93
-70
lines changed

apps/playground/src/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ export default function App() {
127127
<div className="playground-panel-header">
128128
<div className="header-row">
129129
<Logo />
130-
<NavActions showEnvConfig={false} />
130+
<NavActions
131+
showTooltipWhenEmpty={false}
132+
showModelName={false}
133+
/>
131134
</div>
132135
</div>
133136

packages/android/src/device.ts

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import type { ElementInfo } from '@midscene/shared/extractor';
3232
import {
3333
createImgBase64ByFormat,
3434
isValidPNGImageBuffer,
35-
resizeAndConvertImgBuffer,
3635
} from '@midscene/shared/img';
3736
import { getDebug } from '@midscene/shared/logger';
3837
import { uuid } from '@midscene/shared/utils';
@@ -67,6 +66,7 @@ export class AndroidDevice implements AbstractInterface {
6766
private deviceId: string;
6867
private yadbPushed = false;
6968
private devicePixelRatio = 1;
69+
private devicePixelRatioInitialized = false;
7070
private adb: ADB | null = null;
7171
private connectingAdb: Promise<ADB> | null = null;
7272
private destroyed = false;
@@ -583,6 +583,20 @@ ${Object.keys(size)
583583
throw new Error(`Failed to get screen size, output: ${stdout}`);
584584
}
585585

586+
private async initializeDevicePixelRatio(): Promise<void> {
587+
if (this.devicePixelRatioInitialized) {
588+
return;
589+
}
590+
591+
// Get device display density using custom method
592+
const densityNum = await this.getDisplayDensity();
593+
// Standard density is 160, calculate the ratio
594+
this.devicePixelRatio = Number(densityNum) / 160;
595+
debugDevice(`Initialized device pixel ratio: ${this.devicePixelRatio}`);
596+
597+
this.devicePixelRatioInitialized = true;
598+
}
599+
586600
async getDisplayDensity(): Promise<number> {
587601
const adb = await this.getAdb();
588602

@@ -679,6 +693,9 @@ ${Object.keys(size)
679693
}
680694

681695
async size(): Promise<Size> {
696+
// Ensure device pixel ratio is initialized first
697+
await this.initializeDevicePixelRatio();
698+
682699
// Use custom getScreenSize method instead of adb.getScreenSize()
683700
const screenSize = await this.getScreenSize();
684701
// screenSize is a string like "width x height"
@@ -696,16 +713,12 @@ ${Object.keys(size)
696713
const width = Number.parseInt(match[isLandscape ? 2 : 1], 10);
697714
const height = Number.parseInt(match[isLandscape ? 1 : 2], 10);
698715

699-
// Get device display density using custom method
700-
const densityNum = await this.getDisplayDensity();
701-
// Standard density is 160, calculate the ratio
702-
this.devicePixelRatio = Number(densityNum) / 160;
716+
// Use cached device pixel ratio instead of calling getDisplayDensity() every time
703717

704-
// calculate logical pixel size using reverseAdjustCoordinates function
705-
const { x: logicalWidth, y: logicalHeight } = this.reverseAdjustCoordinates(
706-
width,
707-
height,
708-
);
718+
// Convert physical pixels to logical pixels for consistent coordinate system
719+
// adjustCoordinates() will convert back to physical pixels when needed for touch operations
720+
const logicalWidth = Math.round(width / this.devicePixelRatio);
721+
const logicalHeight = Math.round(height / this.devicePixelRatio);
709722

710723
return {
711724
width: logicalWidth,
@@ -722,17 +735,6 @@ ${Object.keys(size)
722735
};
723736
}
724737

725-
private reverseAdjustCoordinates(
726-
x: number,
727-
y: number,
728-
): { x: number; y: number } {
729-
const ratio = this.devicePixelRatio;
730-
return {
731-
x: Math.round(x / ratio),
732-
y: Math.round(y / ratio),
733-
};
734-
}
735-
736738
/**
737739
* Calculate the end point for scroll operations based on start point, scroll delta, and screen boundaries.
738740
* This method ensures that scroll operations stay within screen bounds and maintain a minimum scroll distance
@@ -800,7 +802,6 @@ ${Object.keys(size)
800802

801803
async screenshotBase64(): Promise<string> {
802804
debugDevice('screenshotBase64 begin');
803-
const { width, height } = await this.size();
804805
const adb = await this.getAdb();
805806
let screenshotBuffer;
806807
const androidScreenshotPath = `/data/local/tmp/midscene_screenshot_${uuid()}.png`;
@@ -865,20 +866,11 @@ ${Object.keys(size)
865866
}
866867
}
867868

868-
debugDevice('Resizing screenshot image');
869-
const { buffer, format } = await resizeAndConvertImgBuffer(
870-
// both "adb.takeScreenshot" and "shell screencap" result are png format
869+
debugDevice('Converting to base64');
870+
const result = createImgBase64ByFormat(
871871
'png',
872-
screenshotBuffer,
873-
{
874-
width,
875-
height,
876-
},
872+
screenshotBuffer.toString('base64'),
877873
);
878-
debugDevice('Image resize completed');
879-
880-
debugDevice('Converting to base64');
881-
const result = createImgBase64ByFormat(format, buffer.toString('base64'));
882874
debugDevice('screenshotBase64 end');
883875
return result;
884876
}

packages/ios/src/device.ts

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type IOSDeviceOpt = {
4242
export class IOSDevice implements AbstractInterface {
4343
private deviceId: string;
4444
private devicePixelRatio = 1;
45+
private devicePixelRatioInitialized = false;
4546
private destroyed = false;
4647
private description: string | undefined;
4748
private customActions?: DeviceAction<any>[];
@@ -186,9 +187,7 @@ export class IOSDevice implements AbstractInterface {
186187
}),
187188
call: async (param) => {
188189
const element = param.locate;
189-
if (!element) {
190-
throw new Error('IOSLongPress requires an element to be located');
191-
}
190+
assert(element, 'IOSLongPress requires an element to be located');
192191
const [x, y] = element.center;
193192
await this.longPress(x, y, param?.duration);
194193
},
@@ -227,11 +226,10 @@ export class IOSDevice implements AbstractInterface {
227226
}
228227

229228
public async connect(): Promise<void> {
230-
if (this.destroyed) {
231-
throw new Error(
232-
`IOSDevice ${this.deviceId} has been destroyed and cannot execute commands`,
233-
);
234-
}
229+
assert(
230+
!this.destroyed,
231+
`IOSDevice ${this.deviceId} has been destroyed and cannot execute commands`,
232+
);
235233

236234
debugDevice(`Connecting to iOS device: ${this.deviceId}`);
237235

@@ -261,7 +259,7 @@ Model: ${deviceInfo.model}`
261259
: ''
262260
}
263261
Type: WebDriverAgent
264-
ScreenSize: ${size.width}x${size.height} (DPR: ${this.devicePixelRatio})
262+
ScreenSize: ${size.width}x${size.height} (DPR: ${size.scale})
265263
`;
266264
debugDevice('iOS device connected successfully', this.description);
267265
} catch (e) {
@@ -307,41 +305,48 @@ ScreenSize: ${size.width}x${size.height} (DPR: ${this.devicePixelRatio})
307305
};
308306
}
309307

308+
private async initializeDevicePixelRatio(): Promise<void> {
309+
if (this.devicePixelRatioInitialized) {
310+
return;
311+
}
312+
313+
// Get real device pixel ratio from WebDriverAgent /wda/screen endpoint
314+
const apiScale = await this.wdaBackend.getScreenScale();
315+
316+
assert(
317+
apiScale && apiScale > 0,
318+
'Failed to get device pixel ratio from WebDriverAgent API',
319+
);
320+
321+
debugDevice(`Got screen scale from WebDriverAgent API: ${apiScale}`);
322+
this.devicePixelRatio = apiScale;
323+
this.devicePixelRatioInitialized = true;
324+
}
325+
310326
async getScreenSize(): Promise<{
311327
width: number;
312328
height: number;
313329
scale: number;
314330
}> {
315-
try {
316-
const windowSize = await this.wdaBackend.getWindowSize();
317-
// WDA returns logical points, for our coordinate system we use scale = 1
318-
// This means we work directly with the logical coordinates that WDA provides
319-
const scale = 1; // Use 1 to work with WDA's logical coordinate system directly
320-
321-
return {
322-
width: windowSize.width,
323-
height: windowSize.height,
324-
scale,
325-
};
326-
} catch (e) {
327-
debugDevice(`Failed to get screen size: ${e}`);
328-
// Fallback to default iPhone size with scale 1
329-
return {
330-
width: 402,
331-
height: 874,
332-
scale: 1,
333-
};
334-
}
331+
// Ensure device pixel ratio is initialized
332+
await this.initializeDevicePixelRatio();
333+
334+
const windowSize = await this.wdaBackend.getWindowSize();
335+
336+
return {
337+
width: windowSize.width,
338+
height: windowSize.height,
339+
scale: this.devicePixelRatio,
340+
};
335341
}
336342

337343
async size(): Promise<Size> {
338344
const screenSize = await this.getScreenSize();
339-
this.devicePixelRatio = screenSize.scale;
340345

341346
return {
342347
width: screenSize.width,
343348
height: screenSize.height,
344-
dpr: this.devicePixelRatio,
349+
dpr: screenSize.scale,
345350
};
346351
}
347352

packages/ios/src/ios-webdriver-client.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,20 @@ export class IOSWebDriverClient extends WebDriverClient {
316316
debugIOS(`Double tapped at coordinates (${x}, ${y})`);
317317
}
318318

319+
async getScreenScale(): Promise<number | null> {
320+
// Use the WDA-specific screen endpoint which we confirmed works
321+
const screenResponse = await this.makeRequest('GET', '/wda/screen');
322+
if (screenResponse?.value?.scale) {
323+
debugIOS(
324+
`Got screen scale from WDA screen endpoint: ${screenResponse.value.scale}`,
325+
);
326+
return screenResponse.value.scale;
327+
}
328+
329+
debugIOS('No screen scale found in WDA screen response');
330+
return null;
331+
}
332+
319333
async createSession(capabilities?: any): Promise<any> {
320334
// iOS-specific default capabilities
321335
const defaultCapabilities = {

packages/ios/tests/unit-test/device.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ describe('IOSDevice', () => {
4747
model: 'iPhone 15',
4848
});
4949

50+
// Add getScreenScale mock for new DPR detection
51+
mockWdaClient.getScreenScale = vi.fn().mockResolvedValue(2);
52+
5053
MockedWdaClient.mockImplementation(() => mockWdaClient);
5154

5255
// Setup mock WDA manager
@@ -183,7 +186,7 @@ describe('IOSDevice', () => {
183186
expect(size).toEqual({
184187
width: 375,
185188
height: 812,
186-
dpr: 1,
189+
dpr: 2,
187190
});
188191
expect(mockWdaClient.getWindowSize).toHaveBeenCalled();
189192
});
@@ -295,7 +298,7 @@ describe('IOSDevice', () => {
295298
expect(size).toEqual({
296299
width: 375,
297300
height: 812,
298-
dpr: 1,
301+
dpr: 2,
299302
});
300303
});
301304

@@ -445,6 +448,7 @@ describe('IOSDevice', () => {
445448
createSession: vi.fn().mockResolvedValue({ sessionId: 'test-session' }),
446449
typeText: vi.fn().mockResolvedValue(undefined),
447450
getWindowSize: vi.fn().mockResolvedValue({ width: 375, height: 812 }),
451+
getScreenScale: vi.fn().mockResolvedValue(2),
448452
swipe: vi.fn().mockResolvedValue(undefined),
449453
sessionInfo: { sessionId: 'test-session' }, // Ensure session info is available
450454
};
@@ -470,7 +474,7 @@ describe('IOSDevice', () => {
470474

471475
it('should calculate DPR correctly', async () => {
472476
const size = await device.size();
473-
expect(size.dpr).toBe(1); // Default DPR for mocked device
477+
expect(size.dpr).toBe(2); // DPR from mocked getScreenScale
474478
});
475479

476480
it('should handle different screen sizes', async () => {
@@ -479,7 +483,7 @@ describe('IOSDevice', () => {
479483
.mockResolvedValue({ width: 1920, height: 1080 });
480484

481485
const size = await device.size();
482-
expect(size.width).toBe(1920);
486+
expect(size.width).toBe(1920); // iOS returns logical pixels directly from WDA
483487
expect(size.height).toBe(1080);
484488
});
485489

scripts/dictionary.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,8 @@ watchpack
125125
webm
126126
webp
127127
midscene
128+
mobilesafari
129+
dragfromtoforduration
130+
XCUI
131+
udid
132+
UDID

0 commit comments

Comments
 (0)