aboutsummaryrefslogtreecommitdiff
path: root/catapult/devil/devil/android/sdk/shared_prefs.py
blob: 32b5bc4dce2a24fbaea52cdd7889e3beefe321d5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Helper object to read and modify Shared Preferences from Android apps.

See e.g.:
  http://developer.android.com/reference/android/content/SharedPreferences.html
"""

import logging
import posixpath
from xml.etree import ElementTree

import six

from devil.android import device_errors
from devil.android.sdk import version_codes

logger = logging.getLogger(__name__)

_XML_DECLARATION = "<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n"


class BasePref(object):
  """Base class for getting/setting the value of a specific preference type.

  Should not be instantiated directly. The SharedPrefs collection will
  instantiate the appropriate subclasses, which directly manipulate the
  underlying xml document, to parse and serialize values according to their
  type.

  Args:
    elem: An xml ElementTree object holding the preference data.

  Properties:
    tag_name: A string with the tag that must be used for this preference type.
  """
  tag_name = None

  def __init__(self, elem):
    if elem.tag != type(self).tag_name:
      raise TypeError('Property %r has type %r, but trying to access as %r' %
                      (elem.get('name'), elem.tag, type(self).tag_name))
    self._elem = elem

  def __str__(self):
    """Get the underlying xml element as a string."""
    if six.PY2:
      return ElementTree.tostring(self._elem)
    else:
      return ElementTree.tostring(self._elem, encoding="unicode")

  def get(self):
    """Get the value of this preference."""
    return self._elem.get('value')

  def set(self, value):
    """Set from a value casted as a string."""
    self._elem.set('value', str(value))

  @property
  def has_value(self):
    """Check whether the element has a value."""
    return self._elem.get('value') is not None


class BooleanPref(BasePref):
  """Class for getting/setting a preference with a boolean value.

  The underlying xml element has the form, e.g.:
      <boolean name="featureEnabled" value="false" />
  """
  tag_name = 'boolean'
  VALUES = {'true': True, 'false': False}

  def get(self):
    """Get the value as a Python bool."""
    return type(self).VALUES[super(BooleanPref, self).get()]

  def set(self, value):
    """Set from a value casted as a bool."""
    super(BooleanPref, self).set('true' if value else 'false')


class FloatPref(BasePref):
  """Class for getting/setting a preference with a float value.

  The underlying xml element has the form, e.g.:
      <float name="someMetric" value="4.7" />
  """
  tag_name = 'float'

  def get(self):
    """Get the value as a Python float."""
    return float(super(FloatPref, self).get())


class IntPref(BasePref):
  """Class for getting/setting a preference with an int value.

  The underlying xml element has the form, e.g.:
      <int name="aCounter" value="1234" />
  """
  tag_name = 'int'

  def get(self):
    """Get the value as a Python int."""
    return int(super(IntPref, self).get())


class LongPref(IntPref):
  """Class for getting/setting a preference with a long value.

  The underlying xml element has the form, e.g.:
      <long name="aLongCounter" value="1234" />

  We use the same implementation from IntPref.
  """
  tag_name = 'long'


class StringPref(BasePref):
  """Class for getting/setting a preference with a string value.

  The underlying xml element has the form, e.g.:
      <string name="someHashValue">249b3e5af13d4db2</string>
  """
  tag_name = 'string'

  def get(self):
    """Get the value as a Python string."""
    return self._elem.text

  def set(self, value):
    """Set from a value casted as a string."""
    self._elem.text = str(value)


class StringSetPref(StringPref):
  """Class for getting/setting a preference with a set of string values.

  The underlying xml element has the form, e.g.:
      <set name="managed_apps">
          <string>com.mine.app1</string>
          <string>com.mine.app2</string>
          <string>com.mine.app3</string>
      </set>
  """
  tag_name = 'set'

  def get(self):
    """Get a list with the string values contained."""
    value = []
    for child in self._elem:
      assert child.tag == 'string'
      value.append(child.text)
    return value

  def set(self, value):
    """Set from a sequence of values, each casted as a string."""
    for child in list(self._elem):
      self._elem.remove(child)
    for item in value:
      ElementTree.SubElement(self._elem, 'string').text = str(item)


_PREF_TYPES = {
    c.tag_name: c
    for c in
    [BooleanPref, FloatPref, IntPref, LongPref, StringPref, StringSetPref]
}


class SharedPrefs(object):
  def __init__(self, device, package, filename, use_encrypted_path=False):
    """Helper object to read and update "Shared Prefs" of Android apps.

    Such files typically look like, e.g.:

        <?xml version='1.0' encoding='utf-8' standalone='yes' ?>
        <map>
          <int name="databaseVersion" value="107" />
          <boolean name="featureEnabled" value="false" />
          <string name="someHashValue">249b3e5af13d4db2</string>
        </map>

    Example usage:

        prefs = shared_prefs.SharedPrefs(device, 'com.my.app', 'my_prefs.xml')
        prefs.Load()
        prefs.GetString('someHashValue') # => '249b3e5af13d4db2'
        prefs.SetInt('databaseVersion', 42)
        prefs.Remove('featureEnabled')
        prefs.Commit()

    The object may also be used as a context manager to automatically load and
    commit, respectively, upon entering and leaving the context.

    Args:
      device: A DeviceUtils object.
      package: A string with the package name of the app that owns the shared
        preferences file.
      filename: A string with the name of the preferences file to read/write.
      use_encrypted_path: Whether to read and write to the shared prefs location
        in the device-encrypted path (/data/user_de) instead of the older,
        unencrypted path (/data/data). Only supported on N+, but falls back to
        the unencrypted path if the encrypted path is not supported on the given
        device.
    """
    self._device = device
    self._xml = None
    self._package = package
    self._filename = filename
    self._unencrypted_path = '/data/data/%s/shared_prefs/%s' % (package,
                                                                filename)
    self._encrypted_path = '/data/user_de/0/%s/shared_prefs/%s' % (package,
                                                                   filename)
    self._path = self._unencrypted_path
    self._encrypted = use_encrypted_path
    if use_encrypted_path:
      if self._device.build_version_sdk < version_codes.NOUGAT:
        logging.info('SharedPrefs set to use encrypted path, but given device '
                     'is not running N+. Falling back to unencrypted path')
        self._encrypted = False
      else:
        self._path = self._encrypted_path
    self._changed = False

  def __repr__(self):
    """Get a useful printable representation of the object."""
    return '<{cls} file {filename} for {package} on {device}>'.format(
        cls=type(self).__name__,
        filename=self.filename,
        package=self.package,
        device=str(self._device))

  def __str__(self):
    """Get the underlying xml document as a string."""
    if six.PY2:
      return _XML_DECLARATION + ElementTree.tostring(self.xml)
    else:
      return _XML_DECLARATION + \
          ElementTree.tostring(self.xml, encoding="unicode")

  @property
  def package(self):
    """Get the package name of the app that owns the shared preferences."""
    return self._package

  @property
  def filename(self):
    """Get the filename of the shared preferences file."""
    return self._filename

  @property
  def path(self):
    """Get the full path to the shared preferences file on the device."""
    return self._path

  @property
  def changed(self):
    """True if properties have changed and a commit would be needed."""
    return self._changed

  @property
  def xml(self):
    """Get the underlying xml document as an ElementTree object."""
    if self._xml is None:
      self._xml = ElementTree.Element('map')
    return self._xml

  def Load(self):
    """Load the shared preferences file from the device.

    A empty xml document, which may be modified and saved on |commit|, is
    created if the file does not already exist.
    """
    if self._device.FileExists(self.path):
      self._xml = ElementTree.fromstring(
          self._device.ReadFile(self.path, as_root=True))
      assert self._xml.tag == 'map'
    else:
      self._xml = None
    self._changed = False

  def Clear(self):
    """Clear all of the preferences contained in this object."""
    if self._xml is not None and len(self):  # only clear if not already empty
      self._xml = None
      self._changed = True

  def Commit(self, force_commit=False):
    """Save the current set of preferences to the device.

    Only actually saves if some preferences have been modified or force_commit
    is set to True.

    Args:
      force_commit: Commit even if no changes have been made to the SharedPrefs
        instance.
    """
    if not (self.changed or force_commit):
      return
    self._device.RunShellCommand(
        ['mkdir', '-p', posixpath.dirname(self.path)],
        as_root=True,
        check_return=True)
    self._device.WriteFile(self.path, str(self), as_root=True)
    # Creating the directory/file can cause issues with SELinux if they did
    # not already exist. As a workaround, apply the package's security context
    # to the shared_prefs directory, which mimics the behavior of a file
    # created by the app itself
    if self._device.build_version_sdk >= version_codes.MARSHMALLOW:
      security_context = self._device.GetSecurityContextForPackage(
          self.package, encrypted=self._encrypted)
      if security_context is None:
        raise device_errors.CommandFailedError(
            'Failed to get security context for %s' % self.package)
      paths = [posixpath.dirname(self.path), self.path]
      self._device.ChangeSecurityContext(security_context, paths)

    # Ensure that there isn't both an encrypted and unencrypted version of the
    # file on the device at the same time.
    if self._device.build_version_sdk >= version_codes.NOUGAT:
      remove_path = (self._unencrypted_path
                     if self._encrypted else self._encrypted_path)
      if self._device.PathExists(remove_path, as_root=True):
        logging.warning('Found an equivalent shared prefs file at %s, removing',
                        remove_path)
        self._device.RemovePath(remove_path, as_root=True)

    self._device.KillAll(self.package, exact=True, as_root=True, quiet=True)
    self._changed = False

  def __len__(self):
    """Get the number of preferences in this collection."""
    return len(self.xml)

  def PropertyType(self, key):
    """Get the type (i.e. tag name) of a property in the collection."""
    return self._GetChild(key).tag

  def HasProperty(self, key):
    try:
      self._GetChild(key)
      return True
    except KeyError:
      return False

  def GetBoolean(self, key):
    """Get a boolean property."""
    return BooleanPref(self._GetChild(key)).get()

  def SetBoolean(self, key, value):
    """Set a boolean property."""
    self._SetPrefValue(key, value, BooleanPref)

  def GetFloat(self, key):
    """Get a float property."""
    return FloatPref(self._GetChild(key)).get()

  def SetFloat(self, key, value):
    """Set a float property."""
    self._SetPrefValue(key, value, FloatPref)

  def GetInt(self, key):
    """Get an int property."""
    return IntPref(self._GetChild(key)).get()

  def SetInt(self, key, value):
    """Set an int property."""
    self._SetPrefValue(key, value, IntPref)

  def GetLong(self, key):
    """Get a long property."""
    return LongPref(self._GetChild(key)).get()

  def SetLong(self, key, value):
    """Set a long property."""
    self._SetPrefValue(key, value, LongPref)

  def GetString(self, key):
    """Get a string property."""
    return StringPref(self._GetChild(key)).get()

  def SetString(self, key, value):
    """Set a string property."""
    self._SetPrefValue(key, value, StringPref)

  def GetStringSet(self, key):
    """Get a string set property."""
    return StringSetPref(self._GetChild(key)).get()

  def SetStringSet(self, key, value):
    """Set a string set property."""
    self._SetPrefValue(key, value, StringSetPref)

  def Remove(self, key):
    """Remove a preference from the collection."""
    self.xml.remove(self._GetChild(key))

  def AsDict(self):
    """Return the properties and their values as a dictionary."""
    d = {}
    for child in self.xml:
      pref = _PREF_TYPES[child.tag](child)
      d[child.get('name')] = pref.get()
    return d

  def __enter__(self):
    """Load preferences file from the device when entering a context."""
    self.Load()
    return self

  def __exit__(self, exc_type, _exc_value, _traceback):
    """Save preferences file to the device when leaving a context."""
    if not exc_type:
      self.Commit()

  def _GetChild(self, key):
    """Get the underlying xml node that holds the property of a given key.

    Raises:
      KeyError when the key is not found in the collection.
    """
    for child in self.xml:
      if child.get('name') == key:
        return child
    raise KeyError(key)

  def _SetPrefValue(self, key, value, pref_cls):
    """Set the value of a property.

    Args:
      key: The key of the property to set.
      value: The new value of the property.
      pref_cls: A subclass of BasePref used to access the property.

    Raises:
      TypeError when the key already exists but with a different type.
    """
    try:
      pref = pref_cls(self._GetChild(key))
      old_value = pref.get()
    except KeyError:
      pref = pref_cls(
          ElementTree.SubElement(self.xml, pref_cls.tag_name, {'name': key}))
      old_value = None
    if old_value != value:
      pref.set(value)
      self._changed = True
      logger.info('Setting property: %s', pref)