/* * Copyright (C) 2007 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.ddmlib; import com.android.annotations.NonNull; import com.android.annotations.VisibleForTesting; import com.android.annotations.concurrency.GuardedBy; import com.android.ddmlib.log.LogReceiver; import com.google.common.base.Splitter; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import java.io.File; import java.io.IOException; import java.nio.channels.SocketChannel; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * A Device. It can be a physical device or an emulator. */ final class Device implements IDevice { private static final int INSTALL_TIMEOUT = 2*60*1000; //2min /** Emulator Serial Number regexp. */ static final String RE_EMULATOR_SN = "emulator-(\\d+)"; //$NON-NLS-1$ /** Serial number of the device */ private final String mSerialNumber; /** Name of the AVD */ private String mAvdName = null; /** State of the device. */ private DeviceState mState = null; /** Device properties. */ private final PropertyFetcher mPropFetcher = new PropertyFetcher(this); private final Map<String, String> mMountPoints = new HashMap<String, String>(); private final BatteryFetcher mBatteryFetcher = new BatteryFetcher(this); @GuardedBy("mClients") private final List<Client> mClients = new ArrayList<Client>(); /** Maps pid's of clients in {@link #mClients} to their package name. */ private final Map<Integer, String> mClientInfo = new ConcurrentHashMap<Integer, String>(); private DeviceMonitor mMonitor; private static final String LOG_TAG = "Device"; private static final char SEPARATOR = '-'; private static final String UNKNOWN_PACKAGE = ""; //$NON-NLS-1$ /** * Socket for the connection monitoring client connection/disconnection. */ private SocketChannel mSocketChannel; private Integer mLastBatteryLevel = null; private long mLastBatteryCheckTime = 0; /** Path to the screen recorder binary on the device. */ private static final String SCREEN_RECORDER_DEVICE_PATH = "/system/bin/screenrecord"; private static final long LS_TIMEOUT_SEC = 2; /** Flag indicating whether the device has the screen recorder binary. */ private Boolean mHasScreenRecorder; /** Cached list of hardware characteristics */ private Set<String> mHardwareCharacteristics; private int mApiLevel; private String mName; /** * Output receiver for "pm install package.apk" command line. */ private static final class InstallReceiver extends MultiLineReceiver { private static final String SUCCESS_OUTPUT = "Success"; //$NON-NLS-1$ private static final Pattern FAILURE_PATTERN = Pattern.compile("Failure\\s+\\[(.*)\\]"); //$NON-NLS-1$ private String mErrorMessage = null; public InstallReceiver() { } @Override public void processNewLines(String[] lines) { for (String line : lines) { if (!line.isEmpty()) { if (line.startsWith(SUCCESS_OUTPUT)) { mErrorMessage = null; } else { Matcher m = FAILURE_PATTERN.matcher(line); if (m.matches()) { mErrorMessage = m.group(1); } else { mErrorMessage = "Unknown failure"; } } } } } @Override public boolean isCancelled() { return false; } public String getErrorMessage() { return mErrorMessage; } } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#getSerialNumber() */ @NonNull @Override public String getSerialNumber() { return mSerialNumber; } @Override public String getAvdName() { return mAvdName; } /** * Sets the name of the AVD */ void setAvdName(String avdName) { if (!isEmulator()) { throw new IllegalArgumentException( "Cannot set the AVD name of the device is not an emulator"); } mAvdName = avdName; } @Override public String getName() { if (mName != null) { return mName; } if (isOnline()) { // cache name only if device is online mName = constructName(); return mName; } else { return constructName(); } } private String constructName() { if (isEmulator()) { String avdName = getAvdName(); if (avdName != null) { return String.format("%s [%s]", avdName, getSerialNumber()); } else { return getSerialNumber(); } } else { String manufacturer = null; String model = null; try { manufacturer = cleanupStringForDisplay( getSystemProperty(PROP_DEVICE_MANUFACTURER).get()); model = cleanupStringForDisplay( getSystemProperty(PROP_DEVICE_MODEL).get()); } catch (Exception e) { // If there are exceptions thrown while attempting to get these properties, // we can just use the serial number, so ignore these exceptions. } StringBuilder sb = new StringBuilder(20); if (manufacturer != null) { sb.append(manufacturer); sb.append(SEPARATOR); } if (model != null) { sb.append(model); sb.append(SEPARATOR); } sb.append(getSerialNumber()); return sb.toString(); } } private String cleanupStringForDisplay(String s) { if (s == null) { return null; } StringBuilder sb = new StringBuilder(s.length()); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (Character.isLetterOrDigit(c)) { sb.append(Character.toLowerCase(c)); } else { sb.append('_'); } } return sb.toString(); } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#getState() */ @Override public DeviceState getState() { return mState; } /** * Changes the state of the device. */ void setState(DeviceState state) { mState = state; } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#getProperties() */ @Override public Map<String, String> getProperties() { return Collections.unmodifiableMap(mPropFetcher.getProperties()); } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#getPropertyCount() */ @Override public int getPropertyCount() { return mPropFetcher.getProperties().size(); } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#getProperty(java.lang.String) */ @Override public String getProperty(String name) { Future<String> future = mPropFetcher.getProperty(name); try { return future.get(1, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { // ignore } catch (ExecutionException e) { // ignore } catch (java.util.concurrent.TimeoutException e) { // ignore } return null; } @Override public boolean arePropertiesSet() { return mPropFetcher.arePropertiesSet(); } @Override public String getPropertyCacheOrSync(String name) throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException { Future<String> future = mPropFetcher.getProperty(name); try { return future.get(); } catch (InterruptedException e) { // ignore } catch (ExecutionException e) { // ignore } return null; } @Override public String getPropertySync(String name) throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException { Future<String> future = mPropFetcher.getProperty(name); try { return future.get(); } catch (InterruptedException e) { // ignore } catch (ExecutionException e) { // ignore } return null; } @Override public @NonNull Future<String> getSystemProperty(@NonNull String name) { return mPropFetcher.getProperty(name); } @Override public boolean supportsFeature(Feature feature) { switch (feature) { case SCREEN_RECORD: if (getApiLevel() < 19) { return false; } if (mHasScreenRecorder == null) { mHasScreenRecorder = hasBinary(SCREEN_RECORDER_DEVICE_PATH); } return mHasScreenRecorder; case PROCSTATS: return getApiLevel() >= 19; default: return false; } } // The full list of features can be obtained from /etc/permissions/features* // However, since we only support the "watch" feature, we can determine that by simply // reading the build characteristics property. @Override public boolean supportsFeature(@NonNull HardwareFeature feature) { if (mHardwareCharacteristics == null) { try { String characteristics = getSystemProperty(PROP_BUILD_CHARACTERISTICS).get(); mHardwareCharacteristics = Sets.newHashSet(Splitter.on(',').split(characteristics)); } catch (Exception e) { mHardwareCharacteristics = Collections.emptySet(); } } return mHardwareCharacteristics.contains(feature.getCharacteristic()); } private int getApiLevel() { if (mApiLevel > 0) { return mApiLevel; } try { mApiLevel = Integer.parseInt(getSystemProperty(PROP_BUILD_API_LEVEL).get()); return mApiLevel; } catch (Exception e) { return -1; } } private boolean hasBinary(String path) { CountDownLatch latch = new CountDownLatch(1); CollectingOutputReceiver receiver = new CollectingOutputReceiver(latch); try { executeShellCommand("ls " + path, receiver); } catch (Exception e) { return false; } try { latch.await(LS_TIMEOUT_SEC, TimeUnit.SECONDS); } catch (InterruptedException e) { return false; } String value = receiver.getOutput().trim(); return !value.endsWith("No such file or directory"); } @Override public String getMountPoint(String name) { return mMountPoints.get(name); } @Override public String toString() { return mSerialNumber; } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#isOnline() */ @Override public boolean isOnline() { return mState == DeviceState.ONLINE; } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#isEmulator() */ @Override public boolean isEmulator() { return mSerialNumber.matches(RE_EMULATOR_SN); } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#isOffline() */ @Override public boolean isOffline() { return mState == DeviceState.OFFLINE; } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#isBootLoader() */ @Override public boolean isBootLoader() { return mState == DeviceState.BOOTLOADER; } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#getSyncService() */ @Override public SyncService getSyncService() throws TimeoutException, AdbCommandRejectedException, IOException { SyncService syncService = new SyncService(AndroidDebugBridge.getSocketAddress(), this); if (syncService.openSync()) { return syncService; } return null; } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#getFileListingService() */ @Override public FileListingService getFileListingService() { return new FileListingService(this); } @Override public RawImage getScreenshot() throws TimeoutException, AdbCommandRejectedException, IOException { return AdbHelper.getFrameBuffer(AndroidDebugBridge.getSocketAddress(), this); } @Override public void startScreenRecorder(String remoteFilePath, ScreenRecorderOptions options, IShellOutputReceiver receiver) throws TimeoutException, AdbCommandRejectedException, IOException, ShellCommandUnresponsiveException { executeShellCommand(getScreenRecorderCommand(remoteFilePath, options), receiver, 0, null); } @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) static String getScreenRecorderCommand(@NonNull String remoteFilePath, @NonNull ScreenRecorderOptions options) { StringBuilder sb = new StringBuilder(); sb.append("screenrecord"); sb.append(' '); if (options.width > 0 && options.height > 0) { sb.append("--size "); sb.append(options.width); sb.append('x'); sb.append(options.height); sb.append(' '); } if (options.bitrateMbps > 0) { sb.append("--bit-rate "); sb.append(options.bitrateMbps * 1000000); sb.append(' '); } if (options.timeLimit > 0) { sb.append("--time-limit "); long seconds = TimeUnit.SECONDS.convert(options.timeLimit, options.timeLimitUnits); if (seconds > 180) { seconds = 180; } sb.append(seconds); sb.append(' '); } sb.append(remoteFilePath); return sb.toString(); } @Override public void executeShellCommand(String command, IShellOutputReceiver receiver) throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException { AdbHelper.executeRemoteCommand(AndroidDebugBridge.getSocketAddress(), command, this, receiver, DdmPreferences.getTimeOut()); } @Override public void executeShellCommand(String command, IShellOutputReceiver receiver, int maxTimeToOutputResponse) throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException { AdbHelper.executeRemoteCommand(AndroidDebugBridge.getSocketAddress(), command, this, receiver, maxTimeToOutputResponse); } @Override public void executeShellCommand(String command, IShellOutputReceiver receiver, long maxTimeToOutputResponse, TimeUnit maxTimeUnits) throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException { AdbHelper.executeRemoteCommand(AndroidDebugBridge.getSocketAddress(), command, this, receiver, maxTimeToOutputResponse, maxTimeUnits); } @Override public void runEventLogService(LogReceiver receiver) throws TimeoutException, AdbCommandRejectedException, IOException { AdbHelper.runEventLogService(AndroidDebugBridge.getSocketAddress(), this, receiver); } @Override public void runLogService(String logname, LogReceiver receiver) throws TimeoutException, AdbCommandRejectedException, IOException { AdbHelper.runLogService(AndroidDebugBridge.getSocketAddress(), this, logname, receiver); } @Override public void createForward(int localPort, int remotePort) throws TimeoutException, AdbCommandRejectedException, IOException { AdbHelper.createForward(AndroidDebugBridge.getSocketAddress(), this, String.format("tcp:%d", localPort), //$NON-NLS-1$ String.format("tcp:%d", remotePort)); //$NON-NLS-1$ } @Override public void createForward(int localPort, String remoteSocketName, DeviceUnixSocketNamespace namespace) throws TimeoutException, AdbCommandRejectedException, IOException { AdbHelper.createForward(AndroidDebugBridge.getSocketAddress(), this, String.format("tcp:%d", localPort), //$NON-NLS-1$ String.format("%s:%s", namespace.getType(), remoteSocketName)); //$NON-NLS-1$ } @Override public void removeForward(int localPort, int remotePort) throws TimeoutException, AdbCommandRejectedException, IOException { AdbHelper.removeForward(AndroidDebugBridge.getSocketAddress(), this, String.format("tcp:%d", localPort), //$NON-NLS-1$ String.format("tcp:%d", remotePort)); //$NON-NLS-1$ } @Override public void removeForward(int localPort, String remoteSocketName, DeviceUnixSocketNamespace namespace) throws TimeoutException, AdbCommandRejectedException, IOException { AdbHelper.removeForward(AndroidDebugBridge.getSocketAddress(), this, String.format("tcp:%d", localPort), //$NON-NLS-1$ String.format("%s:%s", namespace.getType(), remoteSocketName)); //$NON-NLS-1$ } Device(DeviceMonitor monitor, String serialNumber, DeviceState deviceState) { mMonitor = monitor; mSerialNumber = serialNumber; mState = deviceState; } DeviceMonitor getMonitor() { return mMonitor; } @Override public boolean hasClients() { synchronized (mClients) { return !mClients.isEmpty(); } } @Override public Client[] getClients() { synchronized (mClients) { return mClients.toArray(new Client[mClients.size()]); } } @Override public Client getClient(String applicationName) { synchronized (mClients) { for (Client c : mClients) { if (applicationName.equals(c.getClientData().getClientDescription())) { return c; } } } return null; } void addClient(Client client) { synchronized (mClients) { mClients.add(client); } addClientInfo(client); } List<Client> getClientList() { return mClients; } void clearClientList() { synchronized (mClients) { mClients.clear(); } clearClientInfo(); } /** * Removes a {@link Client} from the list. * @param client the client to remove. * @param notify Whether or not to notify the listeners of a change. */ void removeClient(Client client, boolean notify) { mMonitor.addPortToAvailableList(client.getDebuggerListenPort()); synchronized (mClients) { mClients.remove(client); } if (notify) { mMonitor.getServer().deviceChanged(this, CHANGE_CLIENT_LIST); } removeClientInfo(client); } /** * Sets the client monitoring socket. * @param socketChannel the sockets */ void setClientMonitoringSocket(SocketChannel socketChannel) { mSocketChannel = socketChannel; } /** * Returns the client monitoring socket. */ SocketChannel getClientMonitoringSocket() { return mSocketChannel; } void update(int changeMask) { mMonitor.getServer().deviceChanged(this, changeMask); } void update(Client client, int changeMask) { mMonitor.getServer().clientChanged(client, changeMask); updateClientInfo(client, changeMask); } void setMountingPoint(String name, String value) { mMountPoints.put(name, value); } private void addClientInfo(Client client) { ClientData cd = client.getClientData(); setClientInfo(cd.getPid(), cd.getClientDescription()); } private void updateClientInfo(Client client, int changeMask) { if ((changeMask & Client.CHANGE_NAME) == Client.CHANGE_NAME) { addClientInfo(client); } } private void removeClientInfo(Client client) { int pid = client.getClientData().getPid(); mClientInfo.remove(pid); } private void clearClientInfo() { mClientInfo.clear(); } private void setClientInfo(int pid, String pkgName) { if (pkgName == null) { pkgName = UNKNOWN_PACKAGE; } mClientInfo.put(pid, pkgName); } @Override public String getClientName(int pid) { String pkgName = mClientInfo.get(pid); return pkgName == null ? UNKNOWN_PACKAGE : pkgName; } @Override public void pushFile(String local, String remote) throws IOException, AdbCommandRejectedException, TimeoutException, SyncException { SyncService sync = null; try { String targetFileName = getFileName(local); Log.d(targetFileName, String.format("Uploading %1$s onto device '%2$s'", targetFileName, getSerialNumber())); sync = getSyncService(); if (sync != null) { String message = String.format("Uploading file onto device '%1$s'", getSerialNumber()); Log.d(LOG_TAG, message); sync.pushFile(local, remote, SyncService.getNullProgressMonitor()); } else { throw new IOException("Unable to open sync connection!"); } } catch (TimeoutException e) { Log.e(LOG_TAG, "Error during Sync: timeout."); throw e; } catch (SyncException e) { Log.e(LOG_TAG, String.format("Error during Sync: %1$s", e.getMessage())); throw e; } catch (IOException e) { Log.e(LOG_TAG, String.format("Error during Sync: %1$s", e.getMessage())); throw e; } finally { if (sync != null) { sync.close(); } } } @Override public void pullFile(String remote, String local) throws IOException, AdbCommandRejectedException, TimeoutException, SyncException { SyncService sync = null; try { String targetFileName = getFileName(remote); Log.d(targetFileName, String.format("Downloading %1$s from device '%2$s'", targetFileName, getSerialNumber())); sync = getSyncService(); if (sync != null) { String message = String.format("Downloading file from device '%1$s'", getSerialNumber()); Log.d(LOG_TAG, message); sync.pullFile(remote, local, SyncService.getNullProgressMonitor()); } else { throw new IOException("Unable to open sync connection!"); } } catch (TimeoutException e) { Log.e(LOG_TAG, "Error during Sync: timeout."); throw e; } catch (SyncException e) { Log.e(LOG_TAG, String.format("Error during Sync: %1$s", e.getMessage())); throw e; } catch (IOException e) { Log.e(LOG_TAG, String.format("Error during Sync: %1$s", e.getMessage())); throw e; } finally { if (sync != null) { sync.close(); } } } @Override public String installPackage(String packageFilePath, boolean reinstall, String... extraArgs) throws InstallException { try { String remoteFilePath = syncPackageToDevice(packageFilePath); String result = installRemotePackage(remoteFilePath, reinstall, extraArgs); removeRemotePackage(remoteFilePath); return result; } catch (IOException e) { throw new InstallException(e); } catch (AdbCommandRejectedException e) { throw new InstallException(e); } catch (TimeoutException e) { throw new InstallException(e); } catch (SyncException e) { throw new InstallException(e); } } @Override public String syncPackageToDevice(String localFilePath) throws IOException, AdbCommandRejectedException, TimeoutException, SyncException { SyncService sync = null; try { String packageFileName = getFileName(localFilePath); String remoteFilePath = String.format("/data/local/tmp/%1$s", packageFileName); //$NON-NLS-1$ Log.d(packageFileName, String.format("Uploading %1$s onto device '%2$s'", packageFileName, getSerialNumber())); sync = getSyncService(); if (sync != null) { String message = String.format("Uploading file onto device '%1$s'", getSerialNumber()); Log.d(LOG_TAG, message); sync.pushFile(localFilePath, remoteFilePath, SyncService.getNullProgressMonitor()); } else { throw new IOException("Unable to open sync connection!"); } return remoteFilePath; } catch (TimeoutException e) { Log.e(LOG_TAG, "Error during Sync: timeout."); throw e; } catch (SyncException e) { Log.e(LOG_TAG, String.format("Error during Sync: %1$s", e.getMessage())); throw e; } catch (IOException e) { Log.e(LOG_TAG, String.format("Error during Sync: %1$s", e.getMessage())); throw e; } finally { if (sync != null) { sync.close(); } } } /** * Helper method to retrieve the file name given a local file path * @param filePath full directory path to file * @return {@link String} file name */ private String getFileName(String filePath) { return new File(filePath).getName(); } @Override public String installRemotePackage(String remoteFilePath, boolean reinstall, String... extraArgs) throws InstallException { try { InstallReceiver receiver = new InstallReceiver(); StringBuilder optionString = new StringBuilder(); if (reinstall) { optionString.append("-r "); } for (String arg : extraArgs) { optionString.append(arg); optionString.append(' '); } String cmd = String.format("pm install %1$s \"%2$s\"", optionString.toString(), remoteFilePath); executeShellCommand(cmd, receiver, INSTALL_TIMEOUT); return receiver.getErrorMessage(); } catch (TimeoutException e) { throw new InstallException(e); } catch (AdbCommandRejectedException e) { throw new InstallException(e); } catch (ShellCommandUnresponsiveException e) { throw new InstallException(e); } catch (IOException e) { throw new InstallException(e); } } @Override public void removeRemotePackage(String remoteFilePath) throws InstallException { try { executeShellCommand(String.format("rm \"%1$s\"", remoteFilePath), new NullOutputReceiver(), INSTALL_TIMEOUT); } catch (IOException e) { throw new InstallException(e); } catch (TimeoutException e) { throw new InstallException(e); } catch (AdbCommandRejectedException e) { throw new InstallException(e); } catch (ShellCommandUnresponsiveException e) { throw new InstallException(e); } } @Override public String uninstallPackage(String packageName) throws InstallException { try { InstallReceiver receiver = new InstallReceiver(); executeShellCommand("pm uninstall " + packageName, receiver, INSTALL_TIMEOUT); return receiver.getErrorMessage(); } catch (TimeoutException e) { throw new InstallException(e); } catch (AdbCommandRejectedException e) { throw new InstallException(e); } catch (ShellCommandUnresponsiveException e) { throw new InstallException(e); } catch (IOException e) { throw new InstallException(e); } } /* * (non-Javadoc) * @see com.android.ddmlib.IDevice#reboot() */ @Override public void reboot(String into) throws TimeoutException, AdbCommandRejectedException, IOException { AdbHelper.reboot(into, AndroidDebugBridge.getSocketAddress(), this); } @Override public Integer getBatteryLevel() throws TimeoutException, AdbCommandRejectedException, IOException, ShellCommandUnresponsiveException { // use default of 5 minutes return getBatteryLevel(5 * 60 * 1000); } @Override public Integer getBatteryLevel(long freshnessMs) throws TimeoutException, AdbCommandRejectedException, IOException, ShellCommandUnresponsiveException { Future<Integer> futureBattery = getBattery(freshnessMs, TimeUnit.MILLISECONDS); try { return futureBattery.get(); } catch (InterruptedException e) { return null; } catch (ExecutionException e) { return null; } } @NonNull @Override public Future<Integer> getBattery() { return getBattery(5, TimeUnit.MINUTES); } @NonNull @Override public Future<Integer> getBattery(long freshnessTime, @NonNull TimeUnit timeUnit) { return mBatteryFetcher.getBattery(freshnessTime, timeUnit); } @NonNull @Override public List<String> getAbis() { /* Try abiList (implemented in L onwards) otherwise fall back to abi and abi2. */ String abiList = getProperty(IDevice.PROP_DEVICE_CPU_ABI_LIST); if(abiList != null) { return Lists.newArrayList(abiList.split(",")); } else { List<String> abis = Lists.newArrayListWithExpectedSize(2); String abi = getProperty(IDevice.PROP_DEVICE_CPU_ABI); if (abi != null) { abis.add(abi); } abi = getProperty(IDevice.PROP_DEVICE_CPU_ABI2); if (abi != null) { abis.add(abi); } return abis; } } @Override public int getDensity() { String densityValue = getProperty(IDevice.PROP_DEVICE_DENSITY); if (densityValue != null) { return Integer.parseInt(densityValue); } return 0; } }