Skip to content

Commit f6fb9e6

Browse files
authored
Add auto-reconnect logic to snippet_client (#82)
This change enhances the `SnippetClient` to automatically handle and recover from connection interruptions, such as socket errors.
1 parent 6a86915 commit f6fb9e6

File tree

2 files changed

+110
-7
lines changed

2 files changed

+110
-7
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright 2025 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Snippet client for Snippet UiAutomator."""
16+
17+
from typing import Any, Sequence
18+
19+
from mobly.controllers import android_device
20+
from mobly.controllers.android_device_lib import snippet_client_v2
21+
from mobly.snippet import errors as snippet_errors
22+
23+
24+
def _list_occupied_adb_ports(ad: android_device.AndroidDevice) -> Sequence[int]:
25+
"""Returns a list of occupied host ports from ADB."""
26+
out = ad.adb.forward('--list')
27+
clean_lines = str(out, 'utf-8').strip().split('\n')
28+
used_ports = []
29+
for line in clean_lines:
30+
tokens = line.split(' tcp:')
31+
if len(tokens) != 3:
32+
continue
33+
used_ports.append(int(tokens[1]))
34+
return used_ports
35+
36+
37+
class SnippetClient(snippet_client_v2.SnippetClientV2):
38+
"""Client for interacting with Snippet UiAutomator on Android Device."""
39+
40+
def __init__(
41+
self,
42+
user_args: Sequence[str],
43+
package: str,
44+
ad: android_device.AndroidDevice,
45+
config: Any = None,
46+
):
47+
self.user_args = user_args
48+
super().__init__(package, ad, config)
49+
50+
def _restart_snippet_connection(self) -> None:
51+
"""Restarts the snippet connection."""
52+
if self.host_port in _list_occupied_adb_ports(self._device):
53+
self.close_connection()
54+
self._adb.shell(['pm', 'clear', *self.user_args, self.package])
55+
self.start_server()
56+
self.make_connection()
57+
58+
def send_rpc_request(self, request: str) -> str:
59+
"""Sends an RPC request to the server and receives a response."""
60+
try:
61+
return super().send_rpc_request(request)
62+
except (snippet_errors.ProtocolError, snippet_errors.Error) as e:
63+
if not isinstance(
64+
e, snippet_errors.ProtocolError
65+
) and 'socket error' not in str(e):
66+
raise
67+
self._device.log.exception(
68+
'Lost connection to the snippet server. Reconnecting...'
69+
)
70+
self._restart_snippet_connection()
71+
return super().send_rpc_request(request)

snippet_uiautomator/uiautomator.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
from mobly import utils as mobly_utils
2424
from mobly.controllers import android_device
2525
from mobly.controllers.android_device_lib import adb
26+
from mobly.controllers.android_device_lib import snippet_client_v2
2627
from mobly.controllers.android_device_lib.services import base_service
27-
from mobly.snippet import errors as snippet_errors
28+
from mobly.controllers.android_device_lib.services import snippet_management_service
2829
from snippet_uiautomator import configurator as uiconfig
2930
from snippet_uiautomator import constants
3031
from snippet_uiautomator import errors
32+
from snippet_uiautomator import snippet_client
3133
from snippet_uiautomator import uidevice
3234
from snippet_uiautomator import uiobject2
3335
from snippet_uiautomator import uiwatcher
@@ -64,13 +66,16 @@ class Snippet:
6466
custom_service_name: The attribute name that has already attached to the
6567
existing snippet client. This can be None if the Snippet UiAutomator is
6668
not wrapped into other snippet apps.
69+
user_id: The user id where the snippet is loaded. If not set, the snippet
70+
will be loaded to the default user.
6771
"""
6872

6973
file_path: str = dataclasses.field(default_factory=utils.get_uiautomator_apk)
7074
package_name: str = UIAUTOMATOR_PACKAGE_NAME
7175
ui_public_service_name: str = PUBLIC_SERVICE_NAME
7276
ui_hidden_service_name: Optional[str] = None
7377
custom_service_name: Optional[str] = None
78+
user_id: Optional[int] = None
7479

7580

7681
@dataclasses.dataclass
@@ -111,13 +116,24 @@ def __init__(
111116
or configs.snippet.ui_hidden_service_name
112117
or HIDDEN_SERVICE_NAME
113118
)
119+
self._user_args = (
120+
[]
121+
if configs.snippet.user_id is None
122+
else ['--user', str(configs.snippet.user_id)]
123+
)
114124
super().__init__(ad, configs)
115125

116126
@property
117127
def _is_apk_installed(self) -> bool:
118128
"""Checks if the snippet apk is already installed."""
119129
all_packages = self._device.adb.shell(
120-
['pm', 'list', 'packages', self._configs.snippet.package_name]
130+
[
131+
'pm',
132+
'list',
133+
'packages',
134+
*self._user_args,
135+
self._configs.snippet.package_name,
136+
]
121137
)
122138
return bool(
123139
mobly_utils.grep(
@@ -138,8 +154,12 @@ def _install_apk(self) -> None:
138154
else:
139155
if self._is_apk_installed:
140156
# In case the existing application is signed with a different key.
141-
self._device.adb.uninstall(self._configs.snippet.package_name)
142-
self._device.adb.install(['-g', self._configs.snippet.file_path])
157+
self._device.adb.uninstall(
158+
[*self._user_args, self._configs.snippet.package_name]
159+
)
160+
self._device.adb.install(
161+
['-g', *self._user_args, self._configs.snippet.file_path]
162+
)
143163

144164
def _load_snippet(self) -> None:
145165
"""Starts the snippet apk with the given package name and connects."""
@@ -154,12 +174,24 @@ def _load_snippet(self) -> None:
154174
raise errors.ConfigurationError(
155175
errors.ERROR_WHEN_PACKAGE_NAME_MISSING, self._device
156176
)
177+
157178
start_time = utils.get_latest_logcat_timestamp(self._device)
158179
try:
159-
self._device.load_snippet(
160-
self._service, self._configs.snippet.package_name
180+
if not self._device.services.has_service_by_name('snippets'):
181+
self._device.services.register(
182+
'snippets', snippet_management_service.SnippetManagementService
183+
)
184+
client = snippet_client.SnippetClient(
185+
user_args=self._user_args,
186+
package=self._configs.snippet.package_name,
187+
ad=self._device,
188+
config=None
189+
if self._configs.snippet.user_id is None
190+
else snippet_client_v2.Config(user_id=self._configs.snippet.user_id),
161191
)
162-
except snippet_errors.ServerStartProtocolError as e:
192+
client.initialize()
193+
snippet_manager._snippet_clients[self._service] = client # pylint: disable=protected-access
194+
except Exception as e:
163195
if utils.is_uiautomator_service_registered(self._device, start_time):
164196
raise errors.UiAutomationServiceAlreadyRegisteredError(
165197
errors.ERROR_WHEN_SERVICE_ALREADY_REGISTERED, self._device

0 commit comments

Comments
 (0)