OLD | NEW |
| (Empty) |
1 // Copyright 2014 The Chromium Authors. All rights reserved. | |
2 // Use of this source code is governed by a BSD-style license that can be | |
3 // found in the LICENSE file. | |
4 | |
5 #include "content/browser/gamepad/raw_input_data_fetcher_win.h" | |
6 | |
7 #include <stddef.h> | |
8 | |
9 #include "base/macros.h" | |
10 #include "base/trace_event/trace_event.h" | |
11 #include "content/common/gamepad_hardware_buffer.h" | |
12 #include "content/common/gamepad_messages.h" | |
13 | |
14 namespace content { | |
15 | |
16 using namespace blink; | |
17 | |
18 namespace { | |
19 | |
20 float NormalizeAxis(long value, long min, long max) { | |
21 return (2.f * (value - min) / static_cast<float>(max - min)) - 1.f; | |
22 } | |
23 | |
24 unsigned long GetBitmask(unsigned short bits) { | |
25 return (1 << bits) - 1; | |
26 } | |
27 | |
28 // From the HID Usage Tables specification. | |
29 USHORT DeviceUsages[] = { | |
30 0x04, // Joysticks | |
31 0x05, // Gamepads | |
32 0x08, // Multi Axis | |
33 }; | |
34 | |
35 const uint32_t kAxisMinimumUsageNumber = 0x30; | |
36 const uint32_t kGameControlsUsagePage = 0x05; | |
37 const uint32_t kButtonUsagePage = 0x09; | |
38 | |
39 } // namespace | |
40 | |
41 RawGamepadInfo::RawGamepadInfo() { | |
42 } | |
43 | |
44 RawGamepadInfo::~RawGamepadInfo() { | |
45 } | |
46 | |
47 RawInputDataFetcher::RawInputDataFetcher() | |
48 : hid_dll_(base::FilePath(FILE_PATH_LITERAL("hid.dll"))), | |
49 rawinput_available_(GetHidDllFunctions()), | |
50 filter_xinput_(true), | |
51 events_monitored_(false) { | |
52 } | |
53 | |
54 RawInputDataFetcher::~RawInputDataFetcher() { | |
55 ClearControllers(); | |
56 DCHECK(!window_); | |
57 DCHECK(!events_monitored_); | |
58 } | |
59 | |
60 void RawInputDataFetcher::WillDestroyCurrentMessageLoop() { | |
61 StopMonitor(); | |
62 } | |
63 | |
64 RAWINPUTDEVICE* RawInputDataFetcher::GetRawInputDevices(DWORD flags) { | |
65 size_t usage_count = arraysize(DeviceUsages); | |
66 std::unique_ptr<RAWINPUTDEVICE[]> devices(new RAWINPUTDEVICE[usage_count]); | |
67 for (size_t i = 0; i < usage_count; ++i) { | |
68 devices[i].dwFlags = flags; | |
69 devices[i].usUsagePage = 1; | |
70 devices[i].usUsage = DeviceUsages[i]; | |
71 devices[i].hwndTarget = (flags & RIDEV_REMOVE) ? 0 : window_->hwnd(); | |
72 } | |
73 return devices.release(); | |
74 } | |
75 | |
76 void RawInputDataFetcher::StartMonitor() { | |
77 if (!rawinput_available_ || events_monitored_) | |
78 return; | |
79 | |
80 if (!window_) { | |
81 window_.reset(new base::win::MessageWindow()); | |
82 if (!window_->Create(base::Bind(&RawInputDataFetcher::HandleMessage, | |
83 base::Unretained(this)))) { | |
84 PLOG(ERROR) << "Failed to create the raw input window"; | |
85 window_.reset(); | |
86 return; | |
87 } | |
88 } | |
89 | |
90 // Register to receive raw HID input. | |
91 std::unique_ptr<RAWINPUTDEVICE[]> devices( | |
92 GetRawInputDevices(RIDEV_INPUTSINK)); | |
93 if (!RegisterRawInputDevices(devices.get(), arraysize(DeviceUsages), | |
94 sizeof(RAWINPUTDEVICE))) { | |
95 PLOG(ERROR) << "RegisterRawInputDevices() failed for RIDEV_INPUTSINK"; | |
96 window_.reset(); | |
97 return; | |
98 } | |
99 | |
100 // Start observing message loop destruction if we start monitoring the first | |
101 // event. | |
102 if (!events_monitored_) | |
103 base::MessageLoop::current()->AddDestructionObserver(this); | |
104 | |
105 events_monitored_ = true; | |
106 } | |
107 | |
108 void RawInputDataFetcher::StopMonitor() { | |
109 if (!rawinput_available_ || !events_monitored_) | |
110 return; | |
111 | |
112 // Stop receiving raw input. | |
113 DCHECK(window_); | |
114 std::unique_ptr<RAWINPUTDEVICE[]> devices(GetRawInputDevices(RIDEV_REMOVE)); | |
115 | |
116 if (!RegisterRawInputDevices(devices.get(), arraysize(DeviceUsages), | |
117 sizeof(RAWINPUTDEVICE))) { | |
118 PLOG(INFO) << "RegisterRawInputDevices() failed for RIDEV_REMOVE"; | |
119 } | |
120 | |
121 events_monitored_ = false; | |
122 window_.reset(); | |
123 | |
124 // Stop observing message loop destruction if no event is being monitored. | |
125 base::MessageLoop::current()->RemoveDestructionObserver(this); | |
126 } | |
127 | |
128 void RawInputDataFetcher::ClearControllers() { | |
129 while (!controllers_.empty()) { | |
130 RawGamepadInfo* gamepad_info = controllers_.begin()->second; | |
131 controllers_.erase(gamepad_info->handle); | |
132 delete gamepad_info; | |
133 } | |
134 } | |
135 | |
136 std::vector<RawGamepadInfo*> RawInputDataFetcher::EnumerateDevices() { | |
137 std::vector<RawGamepadInfo*> valid_controllers; | |
138 | |
139 ClearControllers(); | |
140 | |
141 UINT count = 0; | |
142 UINT result = GetRawInputDeviceList(NULL, &count, sizeof(RAWINPUTDEVICELIST)); | |
143 if (result == static_cast<UINT>(-1)) { | |
144 PLOG(ERROR) << "GetRawInputDeviceList() failed"; | |
145 return valid_controllers; | |
146 } | |
147 DCHECK_EQ(0u, result); | |
148 | |
149 std::unique_ptr<RAWINPUTDEVICELIST[]> device_list( | |
150 new RAWINPUTDEVICELIST[count]); | |
151 result = GetRawInputDeviceList(device_list.get(), &count, | |
152 sizeof(RAWINPUTDEVICELIST)); | |
153 if (result == static_cast<UINT>(-1)) { | |
154 PLOG(ERROR) << "GetRawInputDeviceList() failed"; | |
155 return valid_controllers; | |
156 } | |
157 DCHECK_EQ(count, result); | |
158 | |
159 for (UINT i = 0; i < count; ++i) { | |
160 if (device_list[i].dwType == RIM_TYPEHID) { | |
161 HANDLE device_handle = device_list[i].hDevice; | |
162 RawGamepadInfo* gamepad_info = ParseGamepadInfo(device_handle); | |
163 if (gamepad_info) { | |
164 controllers_[device_handle] = gamepad_info; | |
165 valid_controllers.push_back(gamepad_info); | |
166 } | |
167 } | |
168 } | |
169 return valid_controllers; | |
170 } | |
171 | |
172 RawGamepadInfo* RawInputDataFetcher::GetGamepadInfo(HANDLE handle) { | |
173 std::map<HANDLE, RawGamepadInfo*>::iterator it = controllers_.find(handle); | |
174 if (it != controllers_.end()) | |
175 return it->second; | |
176 | |
177 return NULL; | |
178 } | |
179 | |
180 RawGamepadInfo* RawInputDataFetcher::ParseGamepadInfo(HANDLE hDevice) { | |
181 UINT size = 0; | |
182 | |
183 // Do we already have this device in the map? | |
184 if (GetGamepadInfo(hDevice)) | |
185 return NULL; | |
186 | |
187 // Query basic device info. | |
188 UINT result = GetRawInputDeviceInfo(hDevice, RIDI_DEVICEINFO, | |
189 NULL, &size); | |
190 if (result == static_cast<UINT>(-1)) { | |
191 PLOG(ERROR) << "GetRawInputDeviceInfo() failed"; | |
192 return NULL; | |
193 } | |
194 DCHECK_EQ(0u, result); | |
195 | |
196 std::unique_ptr<uint8_t[]> di_buffer(new uint8_t[size]); | |
197 RID_DEVICE_INFO* device_info = | |
198 reinterpret_cast<RID_DEVICE_INFO*>(di_buffer.get()); | |
199 result = GetRawInputDeviceInfo(hDevice, RIDI_DEVICEINFO, | |
200 di_buffer.get(), &size); | |
201 if (result == static_cast<UINT>(-1)) { | |
202 PLOG(ERROR) << "GetRawInputDeviceInfo() failed"; | |
203 return NULL; | |
204 } | |
205 DCHECK_EQ(size, result); | |
206 | |
207 // Make sure this device is of a type that we want to observe. | |
208 bool valid_type = false; | |
209 for (USHORT device_usage : DeviceUsages) { | |
210 if (device_info->hid.usUsage == device_usage) { | |
211 valid_type = true; | |
212 break; | |
213 } | |
214 } | |
215 | |
216 if (!valid_type) | |
217 return NULL; | |
218 | |
219 std::unique_ptr<RawGamepadInfo> gamepad_info(new RawGamepadInfo); | |
220 gamepad_info->handle = hDevice; | |
221 gamepad_info->report_id = 0; | |
222 gamepad_info->vendor_id = device_info->hid.dwVendorId; | |
223 gamepad_info->product_id = device_info->hid.dwProductId; | |
224 gamepad_info->buttons_length = 0; | |
225 ZeroMemory(gamepad_info->buttons, sizeof(gamepad_info->buttons)); | |
226 gamepad_info->axes_length = 0; | |
227 ZeroMemory(gamepad_info->axes, sizeof(gamepad_info->axes)); | |
228 | |
229 // Query device identifier | |
230 result = GetRawInputDeviceInfo(hDevice, RIDI_DEVICENAME, | |
231 NULL, &size); | |
232 if (result == static_cast<UINT>(-1)) { | |
233 PLOG(ERROR) << "GetRawInputDeviceInfo() failed"; | |
234 return NULL; | |
235 } | |
236 DCHECK_EQ(0u, result); | |
237 | |
238 std::unique_ptr<wchar_t[]> name_buffer(new wchar_t[size]); | |
239 result = GetRawInputDeviceInfo(hDevice, RIDI_DEVICENAME, | |
240 name_buffer.get(), &size); | |
241 if (result == static_cast<UINT>(-1)) { | |
242 PLOG(ERROR) << "GetRawInputDeviceInfo() failed"; | |
243 return NULL; | |
244 } | |
245 DCHECK_EQ(size, result); | |
246 | |
247 // The presence of "IG_" in the device name indicates that this is an XInput | |
248 // Gamepad. Skip enumerating these devices and let the XInput path handle it. | |
249 // http://msdn.microsoft.com/en-us/library/windows/desktop/ee417014.aspx | |
250 if (filter_xinput_ && wcsstr( name_buffer.get(), L"IG_" ) ) | |
251 return NULL; | |
252 | |
253 // Get a friendly device name | |
254 BOOLEAN got_product_string = FALSE; | |
255 HANDLE hid_handle = CreateFile(name_buffer.get(), GENERIC_READ|GENERIC_WRITE, | |
256 FILE_SHARE_READ|FILE_SHARE_WRITE, NULL, OPEN_EXISTING, NULL, NULL); | |
257 if (hid_handle) { | |
258 got_product_string = hidd_get_product_string_(hid_handle, gamepad_info->id, | |
259 sizeof(gamepad_info->id)); | |
260 CloseHandle(hid_handle); | |
261 } | |
262 | |
263 if (!got_product_string) | |
264 swprintf(gamepad_info->id, WebGamepad::idLengthCap, L"Unknown Gamepad"); | |
265 | |
266 // Query device capabilities. | |
267 result = GetRawInputDeviceInfo(hDevice, RIDI_PREPARSEDDATA, | |
268 NULL, &size); | |
269 if (result == static_cast<UINT>(-1)) { | |
270 PLOG(ERROR) << "GetRawInputDeviceInfo() failed"; | |
271 return NULL; | |
272 } | |
273 DCHECK_EQ(0u, result); | |
274 | |
275 gamepad_info->ppd_buffer.reset(new uint8_t[size]); | |
276 gamepad_info->preparsed_data = | |
277 reinterpret_cast<PHIDP_PREPARSED_DATA>(gamepad_info->ppd_buffer.get()); | |
278 result = GetRawInputDeviceInfo(hDevice, RIDI_PREPARSEDDATA, | |
279 gamepad_info->ppd_buffer.get(), &size); | |
280 if (result == static_cast<UINT>(-1)) { | |
281 PLOG(ERROR) << "GetRawInputDeviceInfo() failed"; | |
282 return NULL; | |
283 } | |
284 DCHECK_EQ(size, result); | |
285 | |
286 HIDP_CAPS caps; | |
287 NTSTATUS status = hidp_get_caps_(gamepad_info->preparsed_data, &caps); | |
288 DCHECK_EQ(HIDP_STATUS_SUCCESS, status); | |
289 | |
290 // Query button information. | |
291 USHORT count = caps.NumberInputButtonCaps; | |
292 if (count > 0) { | |
293 std::unique_ptr<HIDP_BUTTON_CAPS[]> button_caps( | |
294 new HIDP_BUTTON_CAPS[count]); | |
295 status = hidp_get_button_caps_( | |
296 HidP_Input, button_caps.get(), &count, gamepad_info->preparsed_data); | |
297 DCHECK_EQ(HIDP_STATUS_SUCCESS, status); | |
298 | |
299 for (uint32_t i = 0; i < count; ++i) { | |
300 if (button_caps[i].Range.UsageMin <= WebGamepad::buttonsLengthCap && | |
301 button_caps[i].UsagePage == kButtonUsagePage) { | |
302 uint32_t max_index = | |
303 std::min(WebGamepad::buttonsLengthCap, | |
304 static_cast<size_t>(button_caps[i].Range.UsageMax)); | |
305 gamepad_info->buttons_length = std::max( | |
306 gamepad_info->buttons_length, max_index); | |
307 } | |
308 } | |
309 } | |
310 | |
311 // Query axis information. | |
312 count = caps.NumberInputValueCaps; | |
313 std::unique_ptr<HIDP_VALUE_CAPS[]> axes_caps(new HIDP_VALUE_CAPS[count]); | |
314 status = hidp_get_value_caps_(HidP_Input, axes_caps.get(), &count, | |
315 gamepad_info->preparsed_data); | |
316 | |
317 bool mapped_all_axes = true; | |
318 | |
319 for (UINT i = 0; i < count; i++) { | |
320 uint32_t axis_index = axes_caps[i].Range.UsageMin - kAxisMinimumUsageNumber; | |
321 if (axis_index < WebGamepad::axesLengthCap) { | |
322 gamepad_info->axes[axis_index].caps = axes_caps[i]; | |
323 gamepad_info->axes[axis_index].value = 0; | |
324 gamepad_info->axes[axis_index].active = true; | |
325 gamepad_info->axes[axis_index].bitmask = GetBitmask(axes_caps[i].BitSize); | |
326 gamepad_info->axes_length = | |
327 std::max(gamepad_info->axes_length, axis_index + 1); | |
328 } else { | |
329 mapped_all_axes = false; | |
330 } | |
331 } | |
332 | |
333 if (!mapped_all_axes) { | |
334 // For axes who's usage puts them outside the standard axesLengthCap range. | |
335 uint32_t next_index = 0; | |
336 for (UINT i = 0; i < count; i++) { | |
337 uint32_t usage = axes_caps[i].Range.UsageMin - kAxisMinimumUsageNumber; | |
338 if (usage >= WebGamepad::axesLengthCap && | |
339 axes_caps[i].UsagePage <= kGameControlsUsagePage) { | |
340 | |
341 for (; next_index < WebGamepad::axesLengthCap; ++next_index) { | |
342 if (!gamepad_info->axes[next_index].active) | |
343 break; | |
344 } | |
345 if (next_index < WebGamepad::axesLengthCap) { | |
346 gamepad_info->axes[next_index].caps = axes_caps[i]; | |
347 gamepad_info->axes[next_index].value = 0; | |
348 gamepad_info->axes[next_index].active = true; | |
349 gamepad_info->axes[next_index].bitmask = GetBitmask( | |
350 axes_caps[i].BitSize); | |
351 gamepad_info->axes_length = | |
352 std::max(gamepad_info->axes_length, next_index + 1); | |
353 } | |
354 } | |
355 | |
356 if (next_index >= WebGamepad::axesLengthCap) | |
357 break; | |
358 } | |
359 } | |
360 | |
361 return gamepad_info.release(); | |
362 } | |
363 | |
364 void RawInputDataFetcher::UpdateGamepad( | |
365 RAWINPUT* input, | |
366 RawGamepadInfo* gamepad_info) { | |
367 NTSTATUS status; | |
368 | |
369 gamepad_info->report_id++; | |
370 | |
371 // Query button state. | |
372 if (gamepad_info->buttons_length) { | |
373 // Clear the button state | |
374 ZeroMemory(gamepad_info->buttons, sizeof(gamepad_info->buttons)); | |
375 ULONG buttons_length = 0; | |
376 | |
377 hidp_get_usages_ex_(HidP_Input, | |
378 0, | |
379 NULL, | |
380 &buttons_length, | |
381 gamepad_info->preparsed_data, | |
382 reinterpret_cast<PCHAR>(input->data.hid.bRawData), | |
383 input->data.hid.dwSizeHid); | |
384 | |
385 std::unique_ptr<USAGE_AND_PAGE[]> usages( | |
386 new USAGE_AND_PAGE[buttons_length]); | |
387 status = | |
388 hidp_get_usages_ex_(HidP_Input, | |
389 0, | |
390 usages.get(), | |
391 &buttons_length, | |
392 gamepad_info->preparsed_data, | |
393 reinterpret_cast<PCHAR>(input->data.hid.bRawData), | |
394 input->data.hid.dwSizeHid); | |
395 | |
396 if (status == HIDP_STATUS_SUCCESS) { | |
397 // Set each reported button to true. | |
398 for (uint32_t j = 0; j < buttons_length; j++) { | |
399 int32_t button_index = usages[j].Usage - 1; | |
400 if (usages[j].UsagePage == kButtonUsagePage && | |
401 button_index >= 0 && | |
402 button_index < | |
403 static_cast<int>(blink::WebGamepad::buttonsLengthCap)) { | |
404 gamepad_info->buttons[button_index] = true; | |
405 } | |
406 } | |
407 } | |
408 } | |
409 | |
410 // Query axis state. | |
411 ULONG axis_value = 0; | |
412 LONG scaled_axis_value = 0; | |
413 for (uint32_t i = 0; i < gamepad_info->axes_length; i++) { | |
414 RawGamepadAxis* axis = &gamepad_info->axes[i]; | |
415 | |
416 // If the min is < 0 we have to query the scaled value, otherwise we need | |
417 // the normal unscaled value. | |
418 if (axis->caps.LogicalMin < 0) { | |
419 status = hidp_get_scaled_usage_value_(HidP_Input, axis->caps.UsagePage, 0, | |
420 axis->caps.Range.UsageMin, &scaled_axis_value, | |
421 gamepad_info->preparsed_data, | |
422 reinterpret_cast<PCHAR>(input->data.hid.bRawData), | |
423 input->data.hid.dwSizeHid); | |
424 if (status == HIDP_STATUS_SUCCESS) { | |
425 axis->value = NormalizeAxis(scaled_axis_value, | |
426 axis->caps.PhysicalMin, axis->caps.PhysicalMax); | |
427 } | |
428 } else { | |
429 status = hidp_get_usage_value_(HidP_Input, axis->caps.UsagePage, 0, | |
430 axis->caps.Range.UsageMin, &axis_value, | |
431 gamepad_info->preparsed_data, | |
432 reinterpret_cast<PCHAR>(input->data.hid.bRawData), | |
433 input->data.hid.dwSizeHid); | |
434 if (status == HIDP_STATUS_SUCCESS) { | |
435 axis->value = NormalizeAxis(axis_value & axis->bitmask, | |
436 axis->caps.LogicalMin & axis->bitmask, | |
437 axis->caps.LogicalMax & axis->bitmask); | |
438 } | |
439 } | |
440 } | |
441 } | |
442 | |
443 LRESULT RawInputDataFetcher::OnInput(HRAWINPUT input_handle) { | |
444 // Get the size of the input record. | |
445 UINT size = 0; | |
446 UINT result = GetRawInputData( | |
447 input_handle, RID_INPUT, NULL, &size, sizeof(RAWINPUTHEADER)); | |
448 if (result == static_cast<UINT>(-1)) { | |
449 PLOG(ERROR) << "GetRawInputData() failed"; | |
450 return 0; | |
451 } | |
452 DCHECK_EQ(0u, result); | |
453 | |
454 // Retrieve the input record. | |
455 std::unique_ptr<uint8_t[]> buffer(new uint8_t[size]); | |
456 RAWINPUT* input = reinterpret_cast<RAWINPUT*>(buffer.get()); | |
457 result = GetRawInputData( | |
458 input_handle, RID_INPUT, buffer.get(), &size, sizeof(RAWINPUTHEADER)); | |
459 if (result == static_cast<UINT>(-1)) { | |
460 PLOG(ERROR) << "GetRawInputData() failed"; | |
461 return 0; | |
462 } | |
463 DCHECK_EQ(size, result); | |
464 | |
465 // Notify the observer about events generated locally. | |
466 if (input->header.dwType == RIM_TYPEHID && input->header.hDevice != NULL) { | |
467 RawGamepadInfo* gamepad = GetGamepadInfo(input->header.hDevice); | |
468 if (gamepad) | |
469 UpdateGamepad(input, gamepad); | |
470 } | |
471 | |
472 return DefRawInputProc(&input, 1, sizeof(RAWINPUTHEADER)); | |
473 } | |
474 | |
475 bool RawInputDataFetcher::HandleMessage(UINT message, | |
476 WPARAM wparam, | |
477 LPARAM lparam, | |
478 LRESULT* result) { | |
479 switch (message) { | |
480 case WM_INPUT: | |
481 *result = OnInput(reinterpret_cast<HRAWINPUT>(lparam)); | |
482 return true; | |
483 | |
484 default: | |
485 return false; | |
486 } | |
487 } | |
488 | |
489 bool RawInputDataFetcher::GetHidDllFunctions() { | |
490 hidp_get_caps_ = NULL; | |
491 hidp_get_button_caps_ = NULL; | |
492 hidp_get_value_caps_ = NULL; | |
493 hidp_get_usages_ex_ = NULL; | |
494 hidp_get_usage_value_ = NULL; | |
495 hidp_get_scaled_usage_value_ = NULL; | |
496 hidd_get_product_string_ = NULL; | |
497 | |
498 if (!hid_dll_.is_valid()) return false; | |
499 | |
500 hidp_get_caps_ = reinterpret_cast<HidPGetCapsFunc>( | |
501 hid_dll_.GetFunctionPointer("HidP_GetCaps")); | |
502 if (!hidp_get_caps_) | |
503 return false; | |
504 hidp_get_button_caps_ = reinterpret_cast<HidPGetButtonCapsFunc>( | |
505 hid_dll_.GetFunctionPointer("HidP_GetButtonCaps")); | |
506 if (!hidp_get_button_caps_) | |
507 return false; | |
508 hidp_get_value_caps_ = reinterpret_cast<HidPGetValueCapsFunc>( | |
509 hid_dll_.GetFunctionPointer("HidP_GetValueCaps")); | |
510 if (!hidp_get_value_caps_) | |
511 return false; | |
512 hidp_get_usages_ex_ = reinterpret_cast<HidPGetUsagesExFunc>( | |
513 hid_dll_.GetFunctionPointer("HidP_GetUsagesEx")); | |
514 if (!hidp_get_usages_ex_) | |
515 return false; | |
516 hidp_get_usage_value_ = reinterpret_cast<HidPGetUsageValueFunc>( | |
517 hid_dll_.GetFunctionPointer("HidP_GetUsageValue")); | |
518 if (!hidp_get_usage_value_) | |
519 return false; | |
520 hidp_get_scaled_usage_value_ = reinterpret_cast<HidPGetScaledUsageValueFunc>( | |
521 hid_dll_.GetFunctionPointer("HidP_GetScaledUsageValue")); | |
522 if (!hidp_get_scaled_usage_value_) | |
523 return false; | |
524 hidd_get_product_string_ = reinterpret_cast<HidDGetStringFunc>( | |
525 hid_dll_.GetFunctionPointer("HidD_GetProductString")); | |
526 if (!hidd_get_product_string_) | |
527 return false; | |
528 | |
529 return true; | |
530 } | |
531 | |
532 } // namespace content | |
OLD | NEW |