What are you trying to achieve?
Toggle the network on/off in an Android test via I.setNetworkConnection(), running against an emulator created from a tablet system image (e.g. Pixel Tablet, ro.build.characteristics=tablet).
What do you get instead?
Every call throws, which takes down whole suites when the call sits in a _beforeSuite hook:
WebDriverError: Error executing adbExec. Original error: 'Command
'/opt/android/platform-tools/adb -P 5037 -s emulator-5554 shell svc data disable'
exited with code 20'; Command output: cmd: Can't find service: phone
at Appium.setNetworkConnection (codeceptjs/lib/helper/Appium.js:979)
This is a regression in 4.1.0. It worked in 4.0.9.
Details
- CodeceptJS version: 4.1.0 (worked in 4.0.9)
- Appium server: 3.1.0,
appium-uiautomator2-driver 5.0.5 - Node: 24.18.0
- Emulator: Android 15,
sdk_gtablet_x86_64, ro.build.characteristics=tablet
Root cause
#5619 (fixed by #5662) replaced the deprecated WebdriverIO setNetworkConnection command with mobile: setConnectivity:
asyncsetNetworkConnection(value){onlyForApps.call(this,supportedPlatform.android)returnthis.browser.execute('mobile: setConnectivity',{airplaneMode: !!(value&1),wifi: !!(value&2),data: !!(value&4),})}mobile: setConnectivity runs adb shell svc <type> <state> for every field it is passed. Because all three fields are always sent, svc data always runs — but tablet system images ship with no telephony at all, so it exits 20 and fails the whole call:
$ adb -s <tablet> shell pm list features | grep -c telephony
0
$ adb -s <tablet> shell service check phone
Service phone: not found
$ adb -s <tablet> shell svc data disable
cmd: Can't find service: phone # exit 20
The legacy command used in 4.0.9 tolerated this.
Reproduced against the Appium API directly
Same device, same session, no CodeceptJS involved:
| request | result |
|---|
POST /session/:id/network_connection{"type":1} (what 4.0.9 used) | {"value":1} ✅ |
mobile: setConnectivity{airplaneMode:true, wifi:false, data:false} (4.1.0) | ❌ Can't find service: phone |
mobile: setConnectivity{airplaneMode:true, wifi:false} | {"value":null} ✅ |
mobile: getConnectivity | {"wifi":true,"data":true,"airplaneMode":true} ✅ |
grabNetworkConnection() is unaffected — mobile: getConnectivity works on these images.
Please don't fix it by dropping data
Worth calling out, because it's the tempting one-liner and it is wrong. On a telephony-capable device, omitting data means setNetworkConnection(1) leaves cellular up, so the device stays online and "go offline" silently does nothing:
# Android 13 emulator (has telephony), after mobile: setConnectivity
{airplaneMode:true, wifi:false} -> Active default network: 106 (MOBILE still CONNECTED)
{airplaneMode:true, wifi:false, data:false} -> Active default network: none
We hit exactly this: a test that goes offline and asserts an unsaved-changes indicator kept passing on the tablet but started failing on the phone image, with no error anywhere.
Proposed fix
Detect whether the device actually has telephony, and only send data when it does. The probe never provokes an exception, so no ERROR webdriver: noise is logged on tablet runs. Cache only the positive result — see the note below on why caching a negative is unsafe:
async setNetworkConnection(value) {
onlyForApps.call(this, supportedPlatform.android)
- return this.browser.execute('mobile: setConnectivity', {- airplaneMode: !!(value & 1),- wifi: !!(value & 2),- data: !!(value & 4),- })+ const connectivity = {+ airplaneMode: !!(value & 1),+ wifi: !!(value & 2),+ data: !!(value & 4),+ }+ // `carrierName` is reported even while airplane mode is on, so it is not affected by+ // the connectivity state being set. Only a positive result is cached: a freshly booted+ // device may not have registered a carrier yet, and caching that would strip `data`+ // for the rest of the session.+ if (!this._hasTelephony) {+ const { carrierName } = await this.browser.execute('mobile: deviceInfo')+ this._hasTelephony = !!carrierName+ }+ // Keep `data` on telephony-capable devices, otherwise the device stays online over+ // cellular and "go offline" silently does nothing.+ if (!this._hasTelephony) delete connectivity.data+ return this.browser.execute('mobile: setConnectivity', connectivity)
}mobile: deviceInfo reports carrierName: "" on the tablet image and e.g. "T-Mobile" on a phone image, and I confirmed it still reports the carrier while airplane mode is enabled, so the probe is not perturbed by the state being changed.
Why !this._hasTelephony and not this._hasTelephony === undefined
=== undefined asks "have I probed yet?", so one empty answer is final. That answer tends to be taken at the worst possible moment: the first setNetworkConnection of a run is typically in a suite-setup hook, seconds after the emulator boots. A telephony-capable image whose modem has not registered a carrier yet reports an empty carrierName there, is classified as telephony-less, and then has data stripped from every later call — leaving the device online over cellular, which is precisely the silent failure this issue argues against. Nothing in the log explains it, because the probe succeeded; it just answered "tablet" about a phone.
!this._hasTelephony asks "do I know it has telephony?" instead, so an empty answer is provisional and re-asked on the next call; once a carrier appears the value sticks and the probe stops. The two conditions differ only when the cached value is false:
_hasTelephony | === undefined | !_hasTelephony |
|---|
undefined — never probed | probe | probe |
false — probed, no carrier seen | skip | re-probe |
true — probed, carrier seen | skip | skip |
The cost is one extra mobile: deviceInfo per call on a genuinely telephony-less device, where the answer never changes. That is a plain info read — it still provokes no exception and adds no ERROR webdriver: line.
An alternative is to send the full payload and catch the Can't find service: phone error, then retry without data. That decides from ground truth and cannot misclassify at all, but WebdriverIO logs a red ERROR webdriver: line for the failed command, which shows up in every tablet run — so the probe is preferable in practice.
Verified
Run as a patch against 4.1.0 on two live emulators — sdk_gtablet_x86_64 (Android 15, ro.build.characteristics=tablet, no telephony feature, service check phone → not found) and sdk_gphone64_x86_64 (Android 13, carrier T-Mobile, service check phone → found):
| suite / test | image | result |
|---|
s4e-tablet NetworkConnectionIndicator | Android 15 tablet | 6 passed |
s4e-tablet NetworkConnectionIndicator | Android 13 phone | 6 passed |
s4e-tablet StudentAttendance | Android 13 phone | 54 passed |
s4h-tablet DndGameWithoutLessonPath | Android 15 tablet | 8 passed |
No ERROR webdriver: line and no Can't find service: phone in any of the four runs.
The Appium server log shows the intended asymmetry. On the telephony-less tablet every call re-probes and data is stripped:
mobile: deviceInfo → mobile: setConnectivity {"airplaneMode":true,"wifi":false}
mobile: deviceInfo → mobile: setConnectivity {"airplaneMode":false,"wifi":true}
On the telephony-capable phone the probe runs once, caches, and data is kept for every later call:
mobile: deviceInfo → mobile: setConnectivity {"airplaneMode":false,"wifi":true,"data":true}
mobile: setConnectivity {"airplaneMode":true,"wifi":false,"data":false}
mobile: setConnectivity {"airplaneMode":false,"wifi":true,"data":true}
StudentAttendance is the test that originally caught the dropped-data failure on the Android 13 image, so its 54 passing scenarios confirm going offline still genuinely disconnects there.
Happy to open a PR if the approach looks right.
— reported by Claude Opus 5 on behalf of @mirao
What are you trying to achieve?
Toggle the network on/off in an Android test via
I.setNetworkConnection(), running against an emulator created from a tablet system image (e.g. Pixel Tablet,ro.build.characteristics=tablet).What do you get instead?
Every call throws, which takes down whole suites when the call sits in a
_beforeSuitehook:This is a regression in 4.1.0. It worked in 4.0.9.
Details
appium-uiautomator2-driver5.0.5sdk_gtablet_x86_64,ro.build.characteristics=tabletRoot cause
#5619 (fixed by #5662) replaced the deprecated WebdriverIO
setNetworkConnectioncommand withmobile: setConnectivity:mobile: setConnectivityrunsadb shell svc <type> <state>for every field it is passed. Because all three fields are always sent,svc dataalways runs — but tablet system images ship with no telephony at all, so it exits 20 and fails the whole call:The legacy command used in 4.0.9 tolerated this.
Reproduced against the Appium API directly
Same device, same session, no CodeceptJS involved:
POST /session/:id/network_connection{"type":1}(what 4.0.9 used){"value":1}✅mobile: setConnectivity{airplaneMode:true, wifi:false, data:false}(4.1.0)Can't find service: phonemobile: setConnectivity{airplaneMode:true, wifi:false}{"value":null}✅mobile: getConnectivity{"wifi":true,"data":true,"airplaneMode":true}✅grabNetworkConnection()is unaffected —mobile: getConnectivityworks on these images.Please don't fix it by dropping
dataWorth calling out, because it's the tempting one-liner and it is wrong. On a telephony-capable device, omitting
datameanssetNetworkConnection(1)leaves cellular up, so the device stays online and "go offline" silently does nothing:We hit exactly this: a test that goes offline and asserts an unsaved-changes indicator kept passing on the tablet but started failing on the phone image, with no error anywhere.
Proposed fix
Detect whether the device actually has telephony, and only send
datawhen it does. The probe never provokes an exception, so noERROR webdriver:noise is logged on tablet runs. Cache only the positive result — see the note below on why caching a negative is unsafe:async setNetworkConnection(value) { onlyForApps.call(this, supportedPlatform.android) - return this.browser.execute('mobile: setConnectivity', {- airplaneMode: !!(value & 1),- wifi: !!(value & 2),- data: !!(value & 4),- })+ const connectivity = {+ airplaneMode: !!(value & 1),+ wifi: !!(value & 2),+ data: !!(value & 4),+ }+ // `carrierName` is reported even while airplane mode is on, so it is not affected by+ // the connectivity state being set. Only a positive result is cached: a freshly booted+ // device may not have registered a carrier yet, and caching that would strip `data`+ // for the rest of the session.+ if (!this._hasTelephony) {+ const { carrierName } = await this.browser.execute('mobile: deviceInfo')+ this._hasTelephony = !!carrierName+ }+ // Keep `data` on telephony-capable devices, otherwise the device stays online over+ // cellular and "go offline" silently does nothing.+ if (!this._hasTelephony) delete connectivity.data+ return this.browser.execute('mobile: setConnectivity', connectivity) }mobile: deviceInforeportscarrierName: ""on the tablet image and e.g."T-Mobile"on a phone image, and I confirmed it still reports the carrier while airplane mode is enabled, so the probe is not perturbed by the state being changed.Why
!this._hasTelephonyand notthis._hasTelephony === undefined=== undefinedasks "have I probed yet?", so one empty answer is final. That answer tends to be taken at the worst possible moment: the firstsetNetworkConnectionof a run is typically in a suite-setup hook, seconds after the emulator boots. A telephony-capable image whose modem has not registered a carrier yet reports an emptycarrierNamethere, is classified as telephony-less, and then hasdatastripped from every later call — leaving the device online over cellular, which is precisely the silent failure this issue argues against. Nothing in the log explains it, because the probe succeeded; it just answered "tablet" about a phone.!this._hasTelephonyasks "do I know it has telephony?" instead, so an empty answer is provisional and re-asked on the next call; once a carrier appears the value sticks and the probe stops. The two conditions differ only when the cached value isfalse:_hasTelephony=== undefined!_hasTelephonyundefined— never probedfalse— probed, no carrier seentrue— probed, carrier seenThe cost is one extra
mobile: deviceInfoper call on a genuinely telephony-less device, where the answer never changes. That is a plain info read — it still provokes no exception and adds noERROR webdriver:line.An alternative is to send the full payload and catch the
Can't find service: phoneerror, then retry withoutdata. That decides from ground truth and cannot misclassify at all, but WebdriverIO logs a redERROR webdriver:line for the failed command, which shows up in every tablet run — so the probe is preferable in practice.Verified
Run as a patch against 4.1.0 on two live emulators —
sdk_gtablet_x86_64(Android 15,ro.build.characteristics=tablet, no telephony feature,service check phone→ not found) andsdk_gphone64_x86_64(Android 13, carrierT-Mobile,service check phone→ found):NetworkConnectionIndicatorNetworkConnectionIndicatorStudentAttendanceDndGameWithoutLessonPathNo
ERROR webdriver:line and noCan't find service: phonein any of the four runs.The Appium server log shows the intended asymmetry. On the telephony-less tablet every call re-probes and
datais stripped:On the telephony-capable phone the probe runs once, caches, and
datais kept for every later call:StudentAttendanceis the test that originally caught the dropped-datafailure on the Android 13 image, so its 54 passing scenarios confirm going offline still genuinely disconnects there.Happy to open a PR if the approach looks right.
— reported by Claude Opus 5 on behalf of @mirao