| /* |
| * Copyright 2021 The Chromium OS Authors. All rights reserved. |
| * Use of this source code is governed by a BSD-style license that can be |
| * found in the LICENSE file. |
| */ |
| |
| #include "common/sensor_hal_client_impl.h" |
| |
| #include <iterator> |
| #include <utility> |
| |
| #include <base/bind.h> |
| #include <base/containers/contains.h> |
| #include <base/logging.h> |
| #include <base/posix/safe_strerror.h> |
| #include <base/stl_util.h> |
| #include <base/strings/string_number_conversions.h> |
| #include <base/time/time.h> |
| |
| #include "cros-camera/common.h" |
| |
| namespace cros { |
| |
| namespace { |
| |
| // The time to wait before HasDevice query times out. |
| constexpr base::TimeDelta kDeviceQueryTimeout = |
| base::TimeDelta::FromMilliseconds(1000); |
| |
| const std::pair<std::string, cros::SensorHalClient::Location> |
| kLocationMapping[] = { |
| {"", cros::SensorHalClient::Location::kNone}, |
| {mojom::kLocationBase, cros::SensorHalClient::Location::kBase}, |
| {mojom::kLocationLid, cros::SensorHalClient::Location::kLid}, |
| {mojom::kLocationCamera, cros::SensorHalClient::Location::kCamera}, |
| }; |
| |
| bool IsSupported(cros::mojom::DeviceType type) { |
| switch (type) { |
| case cros::mojom::DeviceType::ACCEL: |
| return true; |
| |
| case cros::mojom::DeviceType::ANGLVEL: |
| return true; |
| |
| case cros::mojom::DeviceType::GRAVITY: |
| return true; |
| |
| default: |
| return false; |
| } |
| } |
| |
| base::Optional<mojom::DeviceType> ConvertDeviceType( |
| SensorHalClient::DeviceType type) { |
| switch (type) { |
| case SensorHalClient::DeviceType::kAccel: |
| return mojom::DeviceType::ACCEL; |
| |
| case SensorHalClient::DeviceType::kAnglVel: |
| return mojom::DeviceType::ANGLVEL; |
| |
| case SensorHalClient::DeviceType::kGravity: |
| return mojom::DeviceType::GRAVITY; |
| |
| default: |
| return base::nullopt; |
| } |
| } |
| |
| base::Optional<cros::SensorHalClient::Location> ParseLocation( |
| const base::Optional<std::string>& raw_location) { |
| if (!raw_location) { |
| LOGF(WARNING) << "No location attribute"; |
| return cros::SensorHalClient::Location::kNone; |
| } |
| |
| for (size_t i = 0; i < base::size(kLocationMapping); ++i) { |
| if (*raw_location == kLocationMapping[i].first) |
| return kLocationMapping[i].second; |
| } |
| |
| return cros::SensorHalClient::Location::kNone; |
| } |
| |
| } // namespace |
| |
| // static |
| SensorHalClient* SensorHalClient::GetInstance( |
| CameraMojoChannelManagerToken* token) { |
| return CameraMojoChannelManager::FromToken(token)->GetSensorHalClient(); |
| } |
| |
| SensorHalClientImpl::SensorHalClientImpl(CameraMojoChannelManager* mojo_manager) |
| : mojo_manager_(mojo_manager), |
| cancellation_relay_(new CancellationRelay), |
| ipc_bridge_(new IPCBridge(mojo_manager_, cancellation_relay_.get())) {} |
| |
| SensorHalClientImpl::~SensorHalClientImpl() { |
| bool result = mojo_manager_->GetIpcTaskRunner()->DeleteSoon( |
| FROM_HERE, std::move(ipc_bridge_)); |
| DCHECK(result); |
| cancellation_relay_.reset(); |
| } |
| |
| bool SensorHalClientImpl::HasDevice(DeviceType type, Location location) { |
| base::Optional<mojom::DeviceType> device_type = ConvertDeviceType(type); |
| if (!device_type) { |
| return false; |
| } |
| |
| auto future = cros::Future<bool>::Create(cancellation_relay_.get()); |
| mojo_manager_->GetIpcTaskRunner()->PostTask( |
| FROM_HERE, base::BindOnce(&SensorHalClientImpl::IPCBridge::HasDevice, |
| ipc_bridge_->GetWeakPtr(), *device_type, |
| location, GetFutureCallback(future))); |
| |
| if (!future->Wait()) |
| return false; |
| |
| return future->Get(); |
| } |
| |
| bool SensorHalClientImpl::RegisterSamplesObserver( |
| DeviceType type, |
| Location location, |
| double frequency, |
| SamplesObserver* samples_observer) { |
| base::Optional<mojom::DeviceType> device_type = ConvertDeviceType(type); |
| if (!device_type) { |
| return false; |
| } |
| |
| if (frequency <= 0.0) { |
| LOGF(ERROR) << "Invalid frequency: " << frequency; |
| return false; |
| } |
| |
| if (!samples_observer) { |
| LOGF(ERROR) << "Invalid SamplesObserver"; |
| return false; |
| } |
| |
| auto future = cros::Future<bool>::Create(cancellation_relay_.get()); |
| mojo_manager_->GetIpcTaskRunner()->PostTask( |
| FROM_HERE, |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::RegisterSamplesObserver, |
| ipc_bridge_->GetWeakPtr(), *device_type, location, |
| frequency, samples_observer, GetFutureCallback(future))); |
| |
| if (!future->Wait()) |
| return false; |
| |
| return future->Get(); |
| } |
| |
| void SensorHalClientImpl::UnregisterSamplesObserver( |
| SamplesObserver* samples_observer) { |
| if (!samples_observer) { |
| return; |
| } |
| |
| mojo_manager_->GetIpcTaskRunner()->PostTask( |
| FROM_HERE, |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::UnregisterSamplesObserver, |
| ipc_bridge_->GetWeakPtr(), samples_observer)); |
| } |
| |
| SensorHalClientImpl::IPCBridge::IPCBridge( |
| CameraMojoChannelManager* mojo_manager, |
| CancellationRelay* cancellation_relay) |
| : mojo_manager_(mojo_manager), |
| cancellation_relay_(cancellation_relay), |
| ipc_task_runner_(mojo_manager_->GetIpcTaskRunner()) { |
| ipc_task_runner_->PostTask( |
| FROM_HERE, |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::Start, GetWeakPtr())); |
| } |
| |
| SensorHalClientImpl::IPCBridge::~IPCBridge() { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| receiver_.reset(); |
| ResetSensorService(); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::Start() { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| DCHECK(!receiver_.is_bound()); |
| |
| mojo_manager_->RegisterSensorHalClient( |
| receiver_.BindNewPipeAndPassRemote(), |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::OnClientRegistered, |
| GetWeakPtr()), |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::OnServiceMojoChannelError, |
| GetWeakPtr())); |
| receiver_.set_disconnect_handler( |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::OnServiceMojoChannelError, |
| base::Unretained(this))); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::HasDevice( |
| mojom::DeviceType type, |
| Location location, |
| base::OnceCallback<void(bool)> callback) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| if (HasDeviceInternal(type, location)) { |
| std::move(callback).Run(true); |
| return; |
| } |
| |
| if (AreAllDevicesOfTypeInitialized(type)) { |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| uint32_t info_id = device_query_info_counter_++; |
| DeviceQueryInfo info = { |
| .type = type, .location = location, .callback = std::move(callback)}; |
| device_queries_info_.emplace(info_id, std::move(info)); |
| |
| // As there are devices uninitialized, wait for iioservice to report device |
| // info to us before the query times out. |
| ipc_task_runner_->PostDelayedTask( |
| FROM_HERE, |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::OnDeviceQueryTimedOut, |
| GetWeakPtr(), info_id), |
| kDeviceQueryTimeout); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::RegisterSamplesObserver( |
| mojom::DeviceType type, |
| Location location, |
| double frequency, |
| SamplesObserver* samples_observer, |
| base::OnceCallback<void(bool)> callback) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| DCHECK_GT(frequency, 0.0); |
| DCHECK(samples_observer); |
| |
| if (base::Contains(readers_, samples_observer)) { |
| LOGF(ERROR) << "This SamplesObserver is already registered to a device"; |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| if (!HasDeviceInternal(type, location)) { |
| if (AreAllDevicesOfTypeInitialized(type)) { |
| LOGF(ERROR) << "Invalid DeviceType: " << type |
| << " and Location: " << static_cast<int>(location) << " pair"; |
| } else { |
| LOGF(ERROR) << "Not all devices with type: " << type |
| << " have been initialized"; |
| } |
| |
| samples_observer->OnErrorOccurred( |
| SamplesObserver::ErrorType::DEVICE_REMOVED); |
| |
| std::move(callback).Run(false); |
| return; |
| } |
| |
| int32_t iio_device_id = device_maps_[type][location]; |
| DCHECK(devices_[iio_device_id].scale.has_value()); |
| |
| ReaderData reader_data = { |
| .iio_device_id = iio_device_id, |
| .type = type, |
| .frequency = frequency, |
| .sensor_reader = std::make_unique<SensorReader>( |
| ipc_task_runner_, iio_device_id, type, frequency, |
| devices_[iio_device_id].scale.value(), samples_observer, |
| GetSensorDeviceRemote(iio_device_id))}; |
| readers_.emplace(samples_observer, std::move(reader_data)); |
| |
| std::move(callback).Run(true); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::UnregisterSamplesObserver( |
| SamplesObserver* samples_observer) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| DCHECK(samples_observer); |
| |
| readers_.erase(samples_observer); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::SetUpChannel( |
| mojo::PendingRemote<mojom::SensorService> pending_remote) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| if (IsReady()) { |
| LOGF(ERROR) << "Ignoring the second Remote<SensorService>"; |
| return; |
| } |
| |
| sensor_service_remote_.Bind(std::move(pending_remote)); |
| sensor_service_remote_.set_disconnect_handler( |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::OnSensorServiceDisconnect, |
| GetWeakPtr())); |
| |
| sensor_service_remote_->RegisterNewDevicesObserver( |
| new_devices_observer_.BindNewPipeAndPassRemote()); |
| new_devices_observer_.set_disconnect_handler(base::BindOnce( |
| &SensorHalClientImpl::IPCBridge::OnNewDevicesObserverDisconnect, |
| GetWeakPtr())); |
| |
| sensor_service_remote_->GetAllDeviceIds(base::BindOnce( |
| &SensorHalClientImpl::IPCBridge::GetAllDeviceIdsCallback, GetWeakPtr())); |
| |
| // Re-establish mojo channels for the existing observers with SensorReaders. |
| for (auto& [samples_observer, reader_data] : readers_) { |
| reader_data.sensor_reader = std::make_unique<SensorReader>( |
| ipc_task_runner_, reader_data.iio_device_id, reader_data.type, |
| reader_data.frequency, |
| devices_[reader_data.iio_device_id].scale.value(), samples_observer, |
| GetSensorDeviceRemote(reader_data.iio_device_id)); |
| } |
| } |
| |
| void SensorHalClientImpl::IPCBridge::OnNewDeviceAdded( |
| int32_t iio_device_id, const std::vector<mojom::DeviceType>& types) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| if (base::Contains(devices_, iio_device_id)) |
| return; |
| |
| RegisterDevice(iio_device_id, types); |
| } |
| |
| base::WeakPtr<SensorHalClientImpl::IPCBridge> |
| SensorHalClientImpl::IPCBridge::GetWeakPtr() { |
| return weak_ptr_factory_.GetWeakPtr(); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::GetAllDeviceIdsCallback( |
| const base::flat_map<int32_t, std::vector<mojom::DeviceType>>& |
| iio_device_ids_types) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| devices_retrieved_ = true; |
| |
| for (const auto& [iio_device_id, types] : iio_device_ids_types) { |
| RegisterDevice(iio_device_id, types); |
| } |
| } |
| |
| void SensorHalClientImpl::IPCBridge::OnDeviceQueryTimedOut(uint32_t info_id) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| auto it = device_queries_info_.find(info_id); |
| if (it == device_queries_info_.end()) { |
| // Task was already processed. |
| return; |
| } |
| |
| LOGF(ERROR) << "HasDevice query timed out with type: " << it->second.type |
| << ", and location: " << static_cast<int>(it->second.location); |
| |
| std::move(it->second.callback).Run(false); |
| device_queries_info_.erase(it); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::RegisterDevice( |
| int32_t iio_device_id, const std::vector<mojom::DeviceType>& types) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| DeviceData& device = devices_[iio_device_id]; |
| |
| if (device.ignored) |
| return; |
| |
| // This should only be processed once. |
| if (device.types.empty()) { |
| for (const auto& type : types) { |
| if (IsSupported(type)) { |
| device.types.push_back(type); |
| } |
| } |
| } |
| |
| // Not needed. |
| if (device.types.empty()) { |
| device.ignored = true; |
| return; |
| } |
| |
| std::vector<std::string> attr_names; |
| if (!device.location) { |
| attr_names.push_back(mojom::kLocation); |
| } |
| if (!device.scale) { |
| attr_names.push_back(mojom::kScale); |
| } |
| |
| if (attr_names.empty()) { |
| return; |
| } |
| |
| device.remote = GetSensorDeviceRemote(iio_device_id); |
| |
| // Add a temporary disconnect handler to catch failures during sensor |
| // enumeration. SensorDevice will handle disconnection during normal |
| // operation. |
| device.remote.set_disconnect_with_reason_handler( |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::OnSensorDeviceDisconnect, |
| GetWeakPtr(), iio_device_id)); |
| |
| device.remote->GetAttributes( |
| attr_names, |
| base::BindOnce(&SensorHalClientImpl::IPCBridge::GetAttributesCallback, |
| GetWeakPtr(), iio_device_id, attr_names)); |
| } |
| |
| mojo::Remote<mojom::SensorDevice> |
| SensorHalClientImpl::IPCBridge::GetSensorDeviceRemote(int32_t iio_device_id) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| DCHECK(sensor_service_remote_.is_bound()); |
| |
| mojo::Remote<mojom::SensorDevice> sensor_device_remote; |
| auto& device = devices_[iio_device_id]; |
| if (device.remote.is_bound()) { |
| // Reuse the previous remote. |
| sensor_device_remote = std::move(device.remote); |
| } else { |
| sensor_service_remote_->GetDevice( |
| iio_device_id, sensor_device_remote.BindNewPipeAndPassReceiver()); |
| } |
| |
| return sensor_device_remote; |
| } |
| |
| void SensorHalClientImpl::IPCBridge::GetAttributesCallback( |
| int32_t iio_device_id, |
| const std::vector<std::string> attr_names, |
| const std::vector<base::Optional<std::string>>& values) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| auto it = devices_.find(iio_device_id); |
| DCHECK(it != devices_.end()); |
| auto& device = it->second; |
| DCHECK(device.remote.is_bound()); |
| |
| if (attr_names.size() != values.size()) { |
| LOGF(ERROR) << "Size of attribute names: " << attr_names.size() |
| << " doesn't match size of attribute values: " << values.size(); |
| IgnoreDevice(iio_device_id); |
| } |
| |
| for (size_t i = 0; i < attr_names.size(); ++i) { |
| if (attr_names[i] == mojom::kLocation && !device.location) { |
| device.location = ParseLocation(values[i]); |
| if (!device.location) { |
| LOGF(ERROR) << "Failed to parse location: " << values[i].value_or("") |
| << ", with sensor id: " << iio_device_id; |
| IgnoreDevice(iio_device_id); |
| return; |
| } |
| } else if (attr_names[i] == mojom::kScale && !device.scale) { |
| double scale = 0.0; |
| if (!values[i] || !base::StringToDouble(*values[i], &scale)) { |
| LOGF(ERROR) << "Invalid scale: " << values[i].value_or("") |
| << ", for device with id: " << iio_device_id; |
| // Assume the scale to be 1. |
| scale = 1.0; |
| } |
| |
| device.scale = scale; |
| } |
| } |
| |
| DCHECK(device.location && device.scale); |
| |
| for (const auto& type : device.types) { |
| // TODO(b/189998208): Check how to choose from multiple devices with the |
| // same type and location pair. |
| if (!HasDeviceInternal(type, *device.location)) { |
| device_maps_[type][*device.location] = iio_device_id; |
| } |
| |
| RunDeviceQueriesForType(type); |
| } |
| } |
| |
| void SensorHalClientImpl::IPCBridge::IgnoreDevice(int32_t iio_device_id) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| auto& device = devices_[iio_device_id]; |
| device.ignored = true; |
| device.remote.reset(); |
| |
| for (const auto& type : device.types) { |
| RunDeviceQueriesForType(type); |
| } |
| } |
| |
| bool SensorHalClientImpl::IPCBridge::AreAllDevicesOfTypeInitialized( |
| mojom::DeviceType type) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| if (!devices_retrieved_) { |
| return false; |
| } |
| |
| for (auto& [iio_device_id, device] : devices_) { |
| if (device.ignored || !base::Contains(device.types, type)) |
| continue; |
| |
| if (!device.location) |
| return false; |
| } |
| |
| return true; |
| } |
| |
| void SensorHalClientImpl::IPCBridge::RunDeviceQueriesForType( |
| mojom::DeviceType type) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| bool all_initialized = AreAllDevicesOfTypeInitialized(type); |
| |
| for (auto it = device_queries_info_.begin(); |
| it != device_queries_info_.end();) { |
| bool processed = false; |
| if (type == it->second.type) { |
| if (HasDeviceInternal(it->second.type, it->second.location)) { |
| std::move(it->second.callback).Run(true); |
| processed = true; |
| } else if (all_initialized) { |
| std::move(it->second.callback).Run(false); |
| processed = true; |
| } |
| } |
| |
| if (processed) { |
| it = device_queries_info_.erase(it); |
| } else { |
| ++it; |
| } |
| } |
| } |
| |
| bool SensorHalClientImpl::IPCBridge::HasDeviceInternal(mojom::DeviceType type, |
| Location location) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| return base::Contains(device_maps_, type) && |
| base::Contains(device_maps_[type], location); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::OnClientRegistered(int32_t result) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| if (result == 0) |
| return; |
| |
| LOGF(ERROR) << "Failed to register sensor HAL: " |
| << base::safe_strerror(-result); |
| OnServiceMojoChannelError(); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::OnServiceMojoChannelError() { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| // The CameraHalDispatcher Mojo parent is probably dead. We need to restart |
| // another process in order to connect to the new Mojo parent. |
| LOGF(INFO) << "Mojo connection to SensorHalDispatcher is broken"; |
| |
| cancellation_relay_->CancelAllFutures(); |
| receiver_.reset(); |
| ResetSensorService(); |
| |
| for (auto& [samples_observer, reader_data] : readers_) { |
| samples_observer->OnErrorOccurred( |
| SamplesObserver::ErrorType::MOJO_DISCONNECTED); |
| } |
| } |
| |
| void SensorHalClientImpl::IPCBridge::ResetSensorService() { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| for (auto& [id, device] : devices_) { |
| // Only reset the mojo pipe and keep all the other initialized types and |
| // attributes, so that it won't need to be initialized twice when iioservice |
| // restarts and the mojo connection is re-established. |
| device.remote.reset(); |
| } |
| |
| new_devices_observer_.reset(); |
| sensor_service_remote_.reset(); |
| |
| for (auto& [samples_observer, reader_data] : readers_) { |
| reader_data.sensor_reader.reset(); |
| } |
| } |
| |
| void SensorHalClientImpl::IPCBridge::OnSensorServiceDisconnect() { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| LOGF(ERROR) << "Wait for IIO Service's reconnection."; |
| |
| cancellation_relay_->CancelAllFutures(); |
| ResetSensorService(); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::OnNewDevicesObserverDisconnect() { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| LOGF(ERROR) << "Wait for IIO Service's reconnection."; |
| |
| // Assumes IIO Service has crashed and waits for its relaunch. |
| ResetSensorService(); |
| } |
| |
| void SensorHalClientImpl::IPCBridge::OnSensorDeviceDisconnect( |
| int32_t iio_device_id, |
| uint32_t custom_reason_code, |
| const std::string& description) { |
| DCHECK(ipc_task_runner_->BelongsToCurrentThread()); |
| |
| const auto reason = static_cast<cros::mojom::SensorDeviceDisconnectReason>( |
| custom_reason_code); |
| LOG(WARNING) << "SensorDevice disconnected with id: " << iio_device_id |
| << ", reason: " << reason << ", description: " << description; |
| |
| switch (reason) { |
| case cros::mojom::SensorDeviceDisconnectReason::IIOSERVICE_CRASHED: |
| ResetSensorService(); |
| break; |
| |
| case cros::mojom::SensorDeviceDisconnectReason::DEVICE_REMOVED: |
| for (auto it = readers_.begin(); it != readers_.end();) { |
| if (it->second.iio_device_id == iio_device_id) { |
| it->first->OnErrorOccurred( |
| SamplesObserver::ErrorType::DEVICE_REMOVED); |
| it = readers_.erase(it); |
| } else { |
| ++it; |
| } |
| } |
| |
| auto types = devices_[iio_device_id].types; |
| auto location_opt = devices_[iio_device_id].location; |
| devices_.erase(iio_device_id); |
| if (!location_opt.has_value()) |
| break; |
| |
| auto location = location_opt.value(); |
| for (const auto& type : types) { |
| auto& map_type = device_maps_[type]; |
| if (map_type[location] == iio_device_id) { |
| map_type.erase(location); |
| |
| // TODO(b/189998208): Check how to choose from multiple devices with |
| // the same type and location pair. |
| for (auto& device : devices_) { |
| if (base::Contains(device.second.types, type) && |
| device.second.location == location) { |
| map_type[location] = device.first; |
| RunDeviceQueriesForType(type); |
| break; |
| } |
| } |
| } |
| } |
| break; |
| } |
| } |
| |
| } // namespace cros |