slidge 0.1.2__py3-none-any.whl → 0.2.0a0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. slidge/__init__.py +3 -5
  2. slidge/__main__.py +2 -196
  3. slidge/__version__.py +5 -0
  4. slidge/command/adhoc.py +8 -1
  5. slidge/command/admin.py +5 -6
  6. slidge/command/base.py +1 -2
  7. slidge/command/register.py +32 -16
  8. slidge/command/user.py +85 -5
  9. slidge/contact/contact.py +93 -31
  10. slidge/contact/roster.py +54 -39
  11. slidge/core/config.py +13 -7
  12. slidge/core/gateway/base.py +139 -34
  13. slidge/core/gateway/disco.py +2 -4
  14. slidge/core/gateway/mam.py +1 -4
  15. slidge/core/gateway/ping.py +2 -3
  16. slidge/core/gateway/presence.py +1 -1
  17. slidge/core/gateway/registration.py +32 -21
  18. slidge/core/gateway/search.py +3 -5
  19. slidge/core/gateway/session_dispatcher.py +109 -51
  20. slidge/core/gateway/vcard_temp.py +6 -4
  21. slidge/core/mixins/__init__.py +11 -1
  22. slidge/core/mixins/attachment.py +15 -10
  23. slidge/core/mixins/avatar.py +66 -18
  24. slidge/core/mixins/base.py +8 -2
  25. slidge/core/mixins/message.py +11 -7
  26. slidge/core/mixins/message_maker.py +17 -9
  27. slidge/core/mixins/presence.py +14 -4
  28. slidge/core/pubsub.py +54 -212
  29. slidge/core/session.py +65 -33
  30. slidge/db/__init__.py +4 -0
  31. slidge/db/alembic/env.py +64 -0
  32. slidge/db/alembic/script.py.mako +26 -0
  33. slidge/db/alembic/versions/09f27f098baa_add_missing_attributes_in_room.py +36 -0
  34. slidge/db/alembic/versions/29f5280c61aa_store_subject_setter_in_room.py +37 -0
  35. slidge/db/alembic/versions/8d2ced764698_rely_on_db_to_store_contacts_rooms_and_.py +133 -0
  36. slidge/db/alembic/versions/aa9d82a7f6ef_db_creation.py +76 -0
  37. slidge/db/alembic/versions/b33993e87db3_move_everything_to_persistent_db.py +214 -0
  38. slidge/db/alembic/versions/e91195719c2c_store_users_avatars_persistently.py +26 -0
  39. slidge/db/avatar.py +224 -0
  40. slidge/db/meta.py +65 -0
  41. slidge/db/models.py +365 -0
  42. slidge/db/store.py +976 -0
  43. slidge/group/archive.py +13 -14
  44. slidge/group/bookmarks.py +59 -56
  45. slidge/group/participant.py +81 -29
  46. slidge/group/room.py +242 -142
  47. slidge/main.py +201 -0
  48. slidge/migration.py +30 -0
  49. slidge/slixfix/__init__.py +35 -2
  50. slidge/slixfix/roster.py +11 -4
  51. slidge/slixfix/xep_0292/vcard4.py +1 -0
  52. slidge/util/db.py +1 -47
  53. slidge/util/test.py +21 -4
  54. slidge/util/types.py +24 -4
  55. {slidge-0.1.2.dist-info → slidge-0.2.0a0.dist-info}/METADATA +3 -1
  56. slidge-0.2.0a0.dist-info/RECORD +108 -0
  57. slidge/core/cache.py +0 -183
  58. slidge/util/schema.sql +0 -126
  59. slidge/util/sql.py +0 -508
  60. slidge-0.1.2.dist-info/RECORD +0 -96
  61. {slidge-0.1.2.dist-info → slidge-0.2.0a0.dist-info}/LICENSE +0 -0
  62. {slidge-0.1.2.dist-info → slidge-0.2.0a0.dist-info}/WHEEL +0 -0
  63. {slidge-0.1.2.dist-info → slidge-0.2.0a0.dist-info}/entry_points.txt +0 -0
slidge/contact/contact.py CHANGED
@@ -2,7 +2,7 @@ import datetime
2
2
  import logging
3
3
  import warnings
4
4
  from datetime import date
5
- from typing import TYPE_CHECKING, Generic, Iterable, Optional, Union
5
+ from typing import TYPE_CHECKING, Generic, Iterable, Optional, Self, Union
6
6
 
7
7
  from slixmpp import JID, Message, Presence
8
8
  from slixmpp.exceptions import IqError
@@ -10,10 +10,10 @@ from slixmpp.plugins.xep_0292.stanza import VCard4
10
10
  from slixmpp.types import MessageTypes
11
11
 
12
12
  from ..core import config
13
- from ..core.mixins import FullCarbonMixin
14
- from ..core.mixins.avatar import AvatarMixin
13
+ from ..core.mixins import AvatarMixin, FullCarbonMixin, StoredAttributeMixin
15
14
  from ..core.mixins.disco import ContactAccountDiscoMixin
16
15
  from ..core.mixins.recipient import ReactionRecipientMixin, ThreadRecipientMixin
16
+ from ..db.models import Contact
17
17
  from ..util import SubclassableOnce
18
18
  from ..util.types import LegacyUserIdType, MessageOrPresenceTypeVar
19
19
 
@@ -24,6 +24,7 @@ if TYPE_CHECKING:
24
24
 
25
25
  class LegacyContact(
26
26
  Generic[LegacyUserIdType],
27
+ StoredAttributeMixin,
27
28
  AvatarMixin,
28
29
  ContactAccountDiscoMixin,
29
30
  FullCarbonMixin,
@@ -100,7 +101,6 @@ class LegacyContact(
100
101
  """
101
102
  super().__init__()
102
103
  self.session = session
103
- self.user = session.user
104
104
  self.legacy_id: LegacyUserIdType = legacy_id
105
105
  """
106
106
  The legacy identifier of the :term:`Legacy Contact`.
@@ -116,16 +116,40 @@ class LegacyContact(
116
116
 
117
117
  self._name: Optional[str] = None
118
118
 
119
- if self.xmpp.MARK_ALL_MESSAGES:
120
- self._sent_order = list[str]()
121
-
122
119
  self.xmpp = session.xmpp
123
120
  self.jid = JID(self.jid_username + "@" + self.xmpp.boundjid.bare)
124
121
  self.jid.resource = self.RESOURCE
125
- self.log = logging.getLogger(f"{self.user.bare_jid}:{self.jid.bare}")
126
- self.participants = set["LegacyParticipant"]()
127
- self.is_friend: bool = False
128
- self.__added_to_roster = False
122
+ self.log = logging.getLogger(f"{self.user_jid.bare}:{self.jid.bare}")
123
+ self._is_friend: bool = False
124
+ self.added_to_roster = False
125
+
126
+ @property
127
+ def is_friend(self):
128
+ return self._is_friend
129
+
130
+ @is_friend.setter
131
+ def is_friend(self, value: bool):
132
+ if value == self._is_friend:
133
+ return
134
+ self._is_friend = value
135
+ assert self.contact_pk is not None
136
+ self.xmpp.store.contacts.set_friend(self.contact_pk, value)
137
+
138
+ @property
139
+ def participants(self) -> list["LegacyParticipant"]:
140
+ assert self.contact_pk is not None
141
+ from ..group.participant import LegacyParticipant
142
+
143
+ return [
144
+ LegacyParticipant.get_self_or_unique_subclass().from_store(
145
+ self.session, stored, contact=self
146
+ )
147
+ for stored in self.xmpp.store.participants.get_for_contact(self.contact_pk)
148
+ ]
149
+
150
+ @property
151
+ def user_jid(self):
152
+ return self.session.user_jid
129
153
 
130
154
  def __repr__(self):
131
155
  return f"<Contact '{self.legacy_id}'/'{self.jid.bare}'>"
@@ -170,7 +194,7 @@ class LegacyContact(
170
194
  ) -> MessageOrPresenceTypeVar:
171
195
  if carbon and isinstance(stanza, Message):
172
196
  stanza["to"] = self.jid.bare
173
- stanza["from"] = self.user.jid
197
+ stanza["from"] = self.user_jid
174
198
  self._privileged_send(stanza)
175
199
  return stanza # type:ignore
176
200
 
@@ -186,12 +210,13 @@ class LegacyContact(
186
210
  n["nick"] = self.name
187
211
  stanza.append(n)
188
212
  if self.xmpp.MARK_ALL_MESSAGES and is_markable(stanza):
189
- self._sent_order.append(stanza["id"])
190
- stanza["to"] = self.user.jid
213
+ assert self.contact_pk is not None
214
+ self.xmpp.store.contacts.add_to_sent(self.contact_pk, stanza["id"])
215
+ stanza["to"] = self.user_jid
191
216
  stanza.send()
192
217
  return stanza
193
218
 
194
- def get_msg_xmpp_id_up_to(self, horizon_xmpp_id: str):
219
+ def get_msg_xmpp_id_up_to(self, horizon_xmpp_id: str) -> list[str]:
195
220
  """
196
221
  Return XMPP msg ids sent by this contact up to a given XMPP msg id.
197
222
 
@@ -205,15 +230,8 @@ class LegacyContact(
205
230
  :param horizon_xmpp_id: The latest message
206
231
  :return: A list of XMPP ids or None if horizon_xmpp_id was not found
207
232
  """
208
- for i, xmpp_id in enumerate(self._sent_order):
209
- if xmpp_id == horizon_xmpp_id:
210
- break
211
- else:
212
- return
213
- i += 1
214
- res = self._sent_order[:i]
215
- self._sent_order = self._sent_order[i:]
216
- return res
233
+ assert self.contact_pk is not None
234
+ return self.xmpp.store.contacts.pop_sent_up_to(self.contact_pk, horizon_xmpp_id)
217
235
 
218
236
  @property
219
237
  def name(self):
@@ -229,9 +247,19 @@ class LegacyContact(
229
247
  for p in self.participants:
230
248
  p.nickname = n
231
249
  self._name = n
232
- self.xmpp.pubsub.set_nick(user=self.user, jid=self.jid.bare, nick=n)
250
+ assert self.contact_pk is not None
251
+ self.xmpp.store.contacts.update_nick(self.contact_pk, n)
252
+ self.xmpp.pubsub.broadcast_nick(
253
+ user_jid=self.user_jid, jid=self.jid.bare, nick=n
254
+ )
255
+
256
+ def _get_cached_avatar_id(self):
257
+ assert self.contact_pk is not None
258
+ return self.xmpp.store.contacts.get_avatar_legacy_id(self.contact_pk)
233
259
 
234
260
  def _post_avatar_update(self):
261
+ assert self.contact_pk is not None
262
+ self.xmpp.store.contacts.set_avatar(self.contact_pk, self._avatar_pk)
235
263
  for p in self.participants:
236
264
  self.log.debug("Propagating new avatar to %s", p.muc)
237
265
  p.send_last_presence(force=True, no_cache_online=True)
@@ -283,7 +311,7 @@ class LegacyContact(
283
311
  elif country:
284
312
  vcard.add_address(country, locality)
285
313
 
286
- self.xmpp.vcard.set_vcard(self.jid.bare, vcard, {self.user.jid.bare})
314
+ self.xmpp.vcard.set_vcard(self.jid.bare, vcard, {self.user_jid.bare})
287
315
 
288
316
  async def add_to_roster(self, force=False):
289
317
  """
@@ -291,7 +319,7 @@ class LegacyContact(
291
319
 
292
320
  :param force: add even if the contact was already added successfully
293
321
  """
294
- if self.__added_to_roster and not force:
322
+ if self.added_to_roster and not force:
295
323
  return
296
324
  if config.NO_ROSTER_PUSH:
297
325
  log.debug("Roster push request by plugin ignored (--no-roster-push)")
@@ -303,7 +331,7 @@ class LegacyContact(
303
331
  if (n := self.name) is not None:
304
332
  item["name"] = n
305
333
  kw = dict(
306
- jid=self.user.jid,
334
+ jid=self.user_jid,
307
335
  roster_items={self.jid.bare: item},
308
336
  )
309
337
  try:
@@ -325,12 +353,26 @@ class LegacyContact(
325
353
  else:
326
354
  # we only broadcast pubsub events for contacts added to the roster
327
355
  # so if something was set before, we need to push it now
328
- self.__added_to_roster = True
356
+ self.added_to_roster = True
329
357
  self.session.create_task(self.__broadcast_pubsub_items())
330
358
  self.send_last_presence()
331
359
 
332
360
  async def __broadcast_pubsub_items(self):
333
- await self.xmpp.pubsub.broadcast_all(JID(self.jid.bare), self.user.jid)
361
+ cached_avatar = self.get_cached_avatar()
362
+ if cached_avatar is not None:
363
+ await self.xmpp.pubsub.broadcast_avatar(
364
+ self.jid.bare, self.session.user_jid, cached_avatar
365
+ )
366
+ nick = self.name
367
+ from ..core.pubsub import PepNick
368
+
369
+ if nick is not None:
370
+ pep_nick = PepNick(nick)
371
+ await self.xmpp.pubsub.broadcast(
372
+ pep_nick.nick,
373
+ self.jid.bare,
374
+ self.session.user_jid,
375
+ )
334
376
 
335
377
  async def _set_roster(self, **kw):
336
378
  try:
@@ -350,6 +392,8 @@ class LegacyContact(
350
392
  :param text: Optional message from the friend to the user
351
393
  """
352
394
  self.is_friend = True
395
+ assert self.contact_pk is not None
396
+ self.xmpp.store.contacts.set_friend(self.contact_pk, True)
353
397
  self.log.debug("Accepting friend request")
354
398
  presence = self._make_presence(ptype="subscribed", pstatus=text, bare=True)
355
399
  self._send(presence, nick=True)
@@ -415,7 +459,7 @@ class LegacyContact(
415
459
  their 'friends'".
416
460
  """
417
461
  for ptype in "unsubscribe", "unsubscribed", "unavailable":
418
- self.xmpp.send_presence(pfrom=self.jid, pto=self.user.jid.bare, ptype=ptype) # type: ignore
462
+ self.xmpp.send_presence(pfrom=self.jid, pto=self.user_jid.bare, ptype=ptype) # type: ignore
419
463
 
420
464
  async def update_info(self):
421
465
  """
@@ -442,6 +486,24 @@ class LegacyContact(
442
486
  """
443
487
  pass
444
488
 
489
+ @classmethod
490
+ def from_store(cls, session, stored: Contact, *args, **kwargs) -> Self:
491
+ contact = cls(
492
+ session,
493
+ cls.xmpp.LEGACY_CONTACT_ID_TYPE(stored.legacy_id),
494
+ stored.jid.username, # type: ignore
495
+ *args, # type: ignore
496
+ **kwargs, # type: ignore
497
+ )
498
+ contact.contact_pk = stored.id
499
+ contact._name = stored.nick
500
+ contact._is_friend = stored.is_friend
501
+ contact.added_to_roster = stored.added_to_roster
502
+ if (data := stored.extra_attributes) is not None:
503
+ contact.deserialize_extra_attributes(data)
504
+ contact._set_avatar_from_store(stored)
505
+ return contact
506
+
445
507
 
446
508
  def is_markable(stanza: Union[Message, Presence]):
447
509
  if isinstance(stanza, Presence):
slidge/contact/roster.py CHANGED
@@ -1,11 +1,12 @@
1
1
  import asyncio
2
2
  import logging
3
- from typing import TYPE_CHECKING, Generic, Optional, Type
3
+ from typing import TYPE_CHECKING, Generic, Iterator, Optional, Type
4
4
 
5
5
  from slixmpp import JID
6
6
  from slixmpp.jid import JID_UNESCAPE_TRANSFORMATIONS, _unescape_node
7
7
 
8
8
  from ..core.mixins.lock import NamedLockMixin
9
+ from ..db.store import ContactStore
9
10
  from ..util import SubclassableOnce
10
11
  from ..util.types import LegacyContactType, LegacyUserIdType
11
12
  from .contact import LegacyContact
@@ -43,38 +44,45 @@ class LegacyRoster(
43
44
  LegacyContact.get_self_or_unique_subclass()
44
45
  )
45
46
  self._contact_cls.xmpp = session.xmpp
47
+ self.__store: ContactStore = session.xmpp.store.contacts
46
48
 
47
49
  self.session = session
48
- self._contacts_by_bare_jid: dict[str, LegacyContactType] = {}
49
- self._contacts_by_legacy_id: dict[LegacyUserIdType, LegacyContactType] = {}
50
- self.log = logging.getLogger(f"{self.session.user.bare_jid}:roster")
50
+ self.log = logging.getLogger(f"{self.session.user_jid.bare}:roster")
51
51
  self.user_legacy_id: Optional[LegacyUserIdType] = None
52
52
  self.ready: asyncio.Future[bool] = self.session.xmpp.loop.create_future()
53
53
  super().__init__()
54
54
 
55
55
  def __repr__(self):
56
- return f"<Roster of {self.session.user}>"
56
+ return f"<Roster of {self.session.user_jid}>"
57
57
 
58
- def __iter__(self):
59
- return iter(self._contacts_by_legacy_id.values())
58
+ def __iter__(self) -> Iterator[LegacyContactType]:
59
+ with self.__store.session():
60
+ for stored in self.__store.get_all(user_pk=self.session.user_pk):
61
+ yield self._contact_cls.from_store(self.session, stored)
60
62
 
61
63
  async def __finish_init_contact(
62
64
  self, legacy_id: LegacyUserIdType, jid_username: str, *args, **kwargs
63
65
  ):
64
66
  c = self._contact_cls(self.session, legacy_id, jid_username, *args, **kwargs)
65
- async with self.lock(("finish", c)):
66
- if legacy_id in self._contacts_by_legacy_id:
67
- self.log.debug("Already updated %s", c)
68
- return c
69
- await c.avatar_wrap_update_info()
70
- self._contacts_by_legacy_id[legacy_id] = c
71
- self._contacts_by_bare_jid[c.jid.bare] = c
67
+ async with self.lock(("finish", c.legacy_id)):
68
+ with self.__store.session():
69
+ stored = self.__store.get_by_legacy_id(
70
+ self.session.user_pk, str(legacy_id)
71
+ )
72
+ if stored is not None and stored.updated:
73
+ self.log.debug("Already updated %s", c)
74
+ return self._contact_cls.from_store(self.session, stored)
75
+ c.contact_pk = self.__store.add(
76
+ self.session.user_pk, c.legacy_id, c.jid
77
+ )
78
+ await c.avatar_wrap_update_info()
79
+ self.__store.update(c)
72
80
  return c
73
81
 
74
82
  def known_contacts(self, only_friends=True) -> dict[str, LegacyContactType]:
75
83
  if only_friends:
76
- return {j: c for j, c in self._contacts_by_bare_jid.items() if c.is_friend}
77
- return self._contacts_by_bare_jid
84
+ return {j: c for j, c in self if c.is_friend} # type:ignore
85
+ return {c.jid.bare: c for c in self}
78
86
 
79
87
  async def by_jid(self, contact_jid: JID) -> LegacyContactType:
80
88
  # """
@@ -89,16 +97,17 @@ class LegacyRoster(
89
97
  # """
90
98
  username = contact_jid.node
91
99
  async with self.lock(("username", username)):
92
- bare = contact_jid.bare
93
- c = self._contacts_by_bare_jid.get(bare)
94
- if c is None:
95
- legacy_id = await self.jid_username_to_legacy_id(username)
96
- log.debug("Contact %s not found", contact_jid)
97
- if self.get_lock(("legacy_id", legacy_id)):
98
- log.debug("Already updating %s", contact_jid)
99
- return await self.by_legacy_id(legacy_id)
100
- c = await self.__finish_init_contact(legacy_id, username)
101
- return c
100
+ with self.__store.session():
101
+ stored = self.__store.get_by_jid(self.session.user_pk, contact_jid)
102
+ if stored is not None and stored.updated:
103
+ return self._contact_cls.from_store(self.session, stored)
104
+
105
+ legacy_id = await self.jid_username_to_legacy_id(username)
106
+ log.debug("Contact %s not found", contact_jid)
107
+ if self.get_lock(("legacy_id", legacy_id)):
108
+ log.debug("Already updating %s", contact_jid)
109
+ return await self.by_legacy_id(legacy_id)
110
+ return await self.__finish_init_contact(legacy_id, username)
102
111
 
103
112
  async def by_legacy_id(
104
113
  self, legacy_id: LegacyUserIdType, *args, **kwargs
@@ -121,20 +130,26 @@ class LegacyRoster(
121
130
  if legacy_id == self.user_legacy_id:
122
131
  raise ContactIsUser
123
132
  async with self.lock(("legacy_id", legacy_id)):
124
- c = self._contacts_by_legacy_id.get(legacy_id)
125
- if c is None:
126
- username = await self.legacy_id_to_jid_username(legacy_id)
127
- log.debug("Contact %s not found", legacy_id)
128
- if self.get_lock(("username", username)):
129
- log.debug("Already updating %s", username)
130
- jid = JID()
131
- jid.node = username
132
- jid.domain = self.session.xmpp.boundjid.bare
133
- return await self.by_jid(jid)
134
- c = await self.__finish_init_contact(
135
- legacy_id, username, *args, **kwargs
133
+ with self.__store.session():
134
+ stored = self.__store.get_by_legacy_id(
135
+ self.session.user_pk, str(legacy_id)
136
136
  )
137
- return c
137
+ if stored is not None and stored.updated:
138
+ return self._contact_cls.from_store(
139
+ self.session, stored, *args, **kwargs
140
+ )
141
+
142
+ username = await self.legacy_id_to_jid_username(legacy_id)
143
+ log.debug("Contact %s not found", legacy_id)
144
+ if self.get_lock(("username", username)):
145
+ log.debug("Already updating %s", username)
146
+ jid = JID()
147
+ jid.node = username
148
+ jid.domain = self.session.xmpp.boundjid.bare
149
+ return await self.by_jid(jid)
150
+ return await self.__finish_init_contact(
151
+ legacy_id, username, *args, **kwargs
152
+ )
138
153
 
139
154
  async def by_stanza(self, s) -> LegacyContact:
140
155
  # """
slidge/core/config.py CHANGED
@@ -43,11 +43,18 @@ PORT__SHORT = "p"
43
43
 
44
44
  HOME_DIR: Path
45
45
  HOME_DIR__DOC = (
46
- "Shelve file used to store persistent user data. "
46
+ "Directory where slidge will writes it persistent data and cache. "
47
47
  "Defaults to /var/lib/slidge/${SLIDGE_JID}. "
48
48
  )
49
49
  HOME_DIR__DYNAMIC_DEFAULT = True
50
50
 
51
+ DB_URL: str
52
+ DB_URL__DOC = (
53
+ "Database URL, see <https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls>. "
54
+ "Defaults to sqlite:///${HOME_DIR}/slidge.sqlite"
55
+ )
56
+ DB_URL__DYNAMIC_DEFAULT = True
57
+
51
58
  USER_JID_VALIDATOR: str
52
59
  USER_JID_VALIDATOR__DOC = (
53
60
  "Regular expression to restrict users that can register to the gateway, by JID. "
@@ -70,7 +77,7 @@ UPLOAD_SERVICE__DOC = (
70
77
  )
71
78
 
72
79
  SECRET_KEY: Optional[str] = None
73
- SECRET_KEY__DOC = "Encryption for disk storage"
80
+ SECRET_KEY__DOC = "Encryption for disk storage. Deprecated."
74
81
 
75
82
  NO_ROSTER_PUSH = False
76
83
  NO_ROSTER_PUSH__DOC = "Do not fill users' rosters with legacy contacts automatically"
@@ -135,11 +142,13 @@ PARTIAL_REGISTRATION_TIMEOUT__DOC = (
135
142
  "a single step registration process is not enough."
136
143
  )
137
144
 
138
- LAST_SEEN_FALLBACK = True
145
+ LAST_SEEN_FALLBACK = False
139
146
  LAST_SEEN_FALLBACK__DOC = (
140
147
  "When using XEP-0319 (Last User Interaction in Presence), use the presence status"
141
148
  " to display the last seen information in the presence status. Useful for clients"
142
- " that do not implement XEP-0319."
149
+ " that do not implement XEP-0319. Because of implementation details, this can increase"
150
+ " RAM usage and might be deprecated in the future. Ask your client dev for XEP-0319"
151
+ " support ;o)."
143
152
  )
144
153
 
145
154
  QR_TIMEOUT = 60
@@ -211,6 +220,3 @@ DEV_MODE__DOC = (
211
220
  "Enables an interactive python shell via chat commands, for admins."
212
221
  "Not safe to use in prod, but great during dev."
213
222
  )
214
-
215
- SYNC_AVATAR = True
216
- SYNC_AVATAR__DOC = "Sync the user XMPP avatar to legacy network (if supported)."