mirror of
https://github.com/solokeys/openpgp.git
synced 2026-03-11 17:15:00 -07:00
530 lines
19 KiB
C++
530 lines
19 KiB
C++
|
|
#include <string.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <unistd.h>
|
|
#include <stdbool.h>
|
|
|
|
#include "ccid.h"
|
|
#include "usbip.h"
|
|
|
|
/* Device Descriptor */
|
|
const USB_DEVICE_DESCRIPTOR dev_dsc=
|
|
{
|
|
0x12, // Size of this descriptor in bytes
|
|
0x01, // DEVICE descriptor type
|
|
0x0200, // USB Spec Release Number in BCD format
|
|
0x00, // Class Code
|
|
0x00, // Subclass code
|
|
0x00, // Protocol code
|
|
0x10, // Max packet size for EP0, see usb_config.h
|
|
0x072f, // Vendor ID (1209)
|
|
0x90cc, // Product ID (5070)
|
|
0x0100, // Device release number in BCD format
|
|
0x01, // Manufacturer string index
|
|
0x03, // Product string index
|
|
0x04, // Device serial number string index
|
|
0x01 // Number of possible configurations
|
|
};
|
|
|
|
const USB_DEVICE_QUALIFIER_DESCRIPTOR dev_qua = { // A high-speed capable device that has different device information for full-speed and high-speed must have a Device Qualifier Descriptor
|
|
0x0A, // bLength
|
|
0x06, // bDescriptorType
|
|
0x0200, // bcdUSB
|
|
0x00, // bDeviceClass
|
|
0x00, // bDeviceSubClass
|
|
0x00, // bDeviceProtocol
|
|
CCID_DATA_PACKET_SIZE, // bMaxPacketSize
|
|
0x01, // bNumConfigurations
|
|
0x00 // RFU == 0
|
|
};
|
|
|
|
|
|
/* Configuration 1 Descriptor */
|
|
const CONFIG_CCID configuration_ccid={{
|
|
/* Configuration Descriptor */
|
|
0x09,//sizeof(USB_CFG_DSC), // Size of this descriptor in bytes
|
|
USB_DESCRIPTOR_CONFIGURATION, // CONFIGURATION descriptor type
|
|
sizeof(CONFIG_CCID), // Total length of data for this cfg
|
|
1, // Number of interfaces in this cfg
|
|
1, // Index value of this configuration
|
|
0, // Configuration string index
|
|
0xC0, // b8 = 1 mandatory, b7=1 self powered
|
|
50, // Max power consumption (2X mA). 50 = 100mA
|
|
},{
|
|
/* Interface Descriptor */
|
|
0x09,//sizeof(USB_INTF_DSC), // Size of this descriptor in bytes
|
|
USB_DESCRIPTOR_INTERFACE, // INTERFACE descriptor type
|
|
0, // Interface Number
|
|
0, // Alternate Setting Number
|
|
3, // Number of endpoints in this intf
|
|
0x0b, // Class code (CCID class)
|
|
0x00, // Subclass code
|
|
0x00, // Protocol code
|
|
0 // Interface string index
|
|
},{
|
|
/* ICC Descriptor */
|
|
54, // bLength:
|
|
USB_DESCRIPTOR_ICC, // bDescriptorType: USBDESCR_ICC
|
|
0x0100, // bcdCCID: revision 1.1 (of CCID)
|
|
0x00, // bMaxSlotIndex: 0
|
|
0x01, // bVoltageSupport: 5V-only
|
|
0x00000002, // dwProtocols: T=1
|
|
0x00000fa0, // dwDefaultClock: 4000
|
|
0x00000fa0, // dwMaximumClock: 4000
|
|
0x00, // bNumClockSupported: 0x00
|
|
0x00002580, // dwDataRate: 9600
|
|
0x00002580, // dwMaxDataRate: 9600
|
|
0x00, // bNumDataRateSupported: 0x00
|
|
0x000000fe, // dwMaxIFSD: 254
|
|
0x00000000, // dwSynchProtocols: 0
|
|
0x00000000, // dwMechanical: 0
|
|
0x0002047a, /* dwFeatures:
|
|
* Short and extended APDU level: 0x40000 ----
|
|
* Short APDU level : 0x20000 *
|
|
* (ICCD?) : 0x00800 ----
|
|
* Automatic IFSD : 0x00400 *
|
|
* NAD value other than 0x00 : 0x00200
|
|
* Can set ICC in clock stop : 0x00100
|
|
* Automatic PPS CUR : 0x00080
|
|
* Automatic PPS PROP : 0x00040 *
|
|
* Auto baud rate change : 0x00020 *
|
|
* Auto clock change : 0x00010 *
|
|
* Auto voltage selection : 0x00008 *
|
|
* Auto activaction of ICC : 0x00004
|
|
* Automatic conf. based on ATR : 0x00002 *
|
|
*/
|
|
0x0000010f, // dwMaxCCIDMessageLength: 271
|
|
0xff, // bClassGetResponse: 0xff
|
|
0x00, // bClassEnvelope: 0
|
|
0x0000, // wLCDLayout: 0
|
|
0x00, // bPinSupport: No PIN pad
|
|
0x01, // bMaxCCIDBusySlots: 1
|
|
},{
|
|
/* Endpoint Descriptors */
|
|
/* Endpoint IN1 Descriptor */
|
|
sizeof(USB_ENDPOINT_DESCRIPTOR),
|
|
USB_DESCRIPTOR_ENDPOINT, //Endpoint Descriptor
|
|
CCID_IN_EP, //EndpointAddress
|
|
0x02, //bmAttributes: Bulk
|
|
CCID_DATA_PACKET_SIZE, //size // was 34U!!!
|
|
0x00 //Interval
|
|
},{
|
|
/* Endpoint OUT1 Descriptor */
|
|
0x07,/*sizeof(USB_EP_DSC)*/
|
|
USB_DESCRIPTOR_ENDPOINT, //Endpoint Descriptor
|
|
CCID_OUT_EP, //EndpointAddress
|
|
0x02, //bmAttributes: Bulk
|
|
CCID_DATA_PACKET_SIZE, //size
|
|
0x00 //Interval
|
|
},{
|
|
/* Endpoint IN2 Descriptor */
|
|
0x07,/*sizeof(USB_EP_DSC)*/
|
|
USB_DESCRIPTOR_ENDPOINT, //Endpoint Descriptor
|
|
CCID_CMD_EP, //EndpointAddress
|
|
0x03, //bmAttributes: Interrupt
|
|
0x0004, //wMaxPacketSize: 4
|
|
0xff //Interval 255ms
|
|
}
|
|
};
|
|
|
|
|
|
const unsigned char string_0[] = { // available languages descriptor
|
|
0x04,
|
|
USB_DESCRIPTOR_STRING,
|
|
0x09, // 0x0409 (English - United States)
|
|
0x04
|
|
};
|
|
|
|
const unsigned char string_1[] = { // Manufacturer
|
|
0x10,
|
|
USB_DESCRIPTOR_STRING, // bLength, bDscType
|
|
'S', 0x00,
|
|
'o', 0x00,
|
|
'l', 0x00,
|
|
'o', 0x00,
|
|
'D', 0x00,
|
|
'e', 0x00,
|
|
'v', 0x00,
|
|
};
|
|
|
|
const unsigned char string_2[] = {
|
|
0x12,
|
|
USB_DESCRIPTOR_STRING,
|
|
'U', 0x00,
|
|
'S', 0x00,
|
|
'B', 0x00,
|
|
' ', 0x00,
|
|
'C', 0x00,
|
|
'C', 0x00,
|
|
'I', 0x00,
|
|
'D', 0x00,
|
|
};
|
|
|
|
const unsigned char string_3[] = { // product
|
|
0x18,
|
|
USB_DESCRIPTOR_STRING,
|
|
'V', 0x00,
|
|
'i', 0x00,
|
|
'r', 0x00,
|
|
't', 0x00,
|
|
'u', 0x00,
|
|
'a', 0x00,
|
|
'l', 0x00,
|
|
' ', 0x00,
|
|
'U', 0x00,
|
|
'S', 0x00,
|
|
'B', 0x00,
|
|
};
|
|
|
|
const unsigned char string_4[] = { // serial number
|
|
0x18,
|
|
USB_DESCRIPTOR_STRING,
|
|
'1', 0x00,
|
|
'2', 0x00,
|
|
'3', 0x00,
|
|
'4', 0x00,
|
|
'5', 0x00,
|
|
'6', 0x00,
|
|
'7', 0x00,
|
|
'8', 0x00,
|
|
'9', 0x00,
|
|
'A', 0x00,
|
|
'B', 0x00,
|
|
};
|
|
|
|
|
|
const char *configuration = (const char *)&configuration_ccid;
|
|
|
|
const USB_INTERFACE_DESCRIPTOR *interfaces[] = {&configuration_ccid.dev_int0};
|
|
|
|
const unsigned char *strings[] = {string_0, string_1, string_2, string_3, string_4};
|
|
|
|
|
|
#define BSIZE 2048
|
|
static uint8_t buffer[BSIZE + 1];
|
|
static size_t bsize = 0;
|
|
|
|
static uint8_t bufferout[BSIZE + 1];
|
|
static size_t bsizeout = 0;
|
|
|
|
bool ICCStateChanged = true;
|
|
bool ICCPowered = false;
|
|
|
|
bool ProcessCCIDTransfer(uint8_t *datain, size_t datainlen, uint8_t *dataout, size_t *dataoutlen);
|
|
|
|
void handle_data(int sockfd, USBIP_RET_SUBMIT *usb_req, int bl) {
|
|
// data channel
|
|
if(usb_req->ep == 0x04)
|
|
{
|
|
#ifdef _DEBUGCLI
|
|
printf("##Data (EP4) received \n");
|
|
#endif // _DEBUGCLI
|
|
|
|
if(usb_req->direction == 0) //input
|
|
{
|
|
#ifdef _DEBUGCLI
|
|
printf("EP4 direction=input\n");
|
|
#endif // _DEBUGCLI
|
|
bsize=recv (sockfd, (char *)buffer, bl, 0);
|
|
|
|
bool res = ProcessCCIDTransfer(buffer, bsize, bufferout, &bsizeout);
|
|
// ACK
|
|
send_usb_req(sockfd, usb_req, nullptr, 0, res ? 0 : 1);
|
|
}
|
|
else
|
|
{
|
|
#ifdef _DEBUGCLI
|
|
printf("EP4 direction=output\n");
|
|
#endif // _DEBUGCLI
|
|
send_usb_req(sockfd, usb_req, (char *)bufferout, bsizeout, 0);
|
|
bsizeout = 0;
|
|
}
|
|
}
|
|
|
|
// Interrupt channel
|
|
if((usb_req->ep == 0x05)) {
|
|
#ifdef _DEBUGCLI
|
|
printf("##Interrupt (EP5) received \n");
|
|
#endif // _DEBUGCLI
|
|
if(usb_req->direction == 0) {
|
|
printf("EP5 direction=input. WARNNING!!!!\n");
|
|
//not supported
|
|
send_usb_req(sockfd, usb_req, nullptr, 0, 0);
|
|
//usleep(500);
|
|
} else {
|
|
#ifdef _DEBUGCLI
|
|
printf("EP5 direction=output\n");
|
|
#endif // _DEBUGCLI
|
|
|
|
// b0 - slot0 current state b1 - slot0 changed state
|
|
uint8_t state = (ICCPowered ? ICC_PRESENT : ICC_NOT_PRESENT) | (ICCStateChanged ? ICC_CHANGE : 0x00);
|
|
uint8_t data[] = {RDR_TO_PC_NOTIFYSLOTCHANGE, state};
|
|
ICCStateChanged = false;
|
|
send_usb_req(sockfd, usb_req, (char*)data, 2, 0);
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
typedef struct _LINE_CODING
|
|
{
|
|
word dwDTERate; //in bits per second
|
|
byte bCharFormat;//0-1 stop; 1-1.5 stop; 2-2 stop bits
|
|
byte ParityType; //0 none; 1- odd; 2 -even; 3-mark; 4 -space
|
|
byte bDataBits; //5,6,7,8 or 16
|
|
}LINE_CODING;
|
|
|
|
|
|
|
|
LINE_CODING linec;
|
|
|
|
unsigned short linecs=0;
|
|
|
|
void handle_unknown_control(int sockfd, StandardDeviceRequest * control_req, USBIP_RET_SUBMIT *usb_req)
|
|
{
|
|
if(control_req->bmRequestType == 0x21)//Abstract Control Model Requests
|
|
{
|
|
if(control_req->bRequest == 0x20) //SET_LINE_CODING
|
|
{
|
|
printf("SET_LINE_CODING\n");
|
|
if ((recv (sockfd, (char *) &linec , control_req->wLength, 0)) != control_req->wLength)
|
|
{
|
|
printf ("receive error : %s \n", strerror (errno));
|
|
exit(-1);
|
|
};
|
|
send_usb_req(sockfd,usb_req,nullptr,0,0);
|
|
}
|
|
if(control_req->bRequest == 0x21) //GET_LINE_CODING
|
|
{
|
|
printf("GET_LINE_CODING\n");
|
|
send_usb_req(sockfd,usb_req,(char *)&linec,7,0);
|
|
}
|
|
if(control_req->bRequest == 0x22) //SET_LINE_CONTROL_STATE
|
|
{
|
|
linecs=control_req->wValue0;
|
|
printf("SET_LINE_CONTROL_STATE 0x%02X\n", linecs);
|
|
send_usb_req(sockfd,usb_req,nullptr,0,0);
|
|
}
|
|
if(control_req->bRequest == 0x23) //SEND_BREAK
|
|
{
|
|
printf("SEND_BREAK\n");
|
|
send_usb_req(sockfd,usb_req,nullptr,0,0);
|
|
}
|
|
}
|
|
|
|
};
|
|
|
|
static ex_cb exchange_callback = nullptr;
|
|
int usbip_ccid_start(ex_cb cb) {
|
|
exchange_callback = cb;
|
|
printf("ccid started....\n");
|
|
usbip_run(&dev_dsc);
|
|
printf("ccid stopped....\n");
|
|
return 0;
|
|
}
|
|
|
|
#define ABDATA_SIZE 261
|
|
|
|
typedef struct {
|
|
uint8_t bMessageType; /* Offset = 0*/
|
|
uint32_t dwLength; /* Offset = 1, The length field (dwLength) is the length
|
|
of the message not including the 10-byte header.*/
|
|
uint8_t bSlot; /* Offset = 5*/
|
|
uint8_t bSeq; /* Offset = 6*/
|
|
uint8_t bSpecific_0; /* Offset = 7*/
|
|
uint8_t bSpecific_1; /* Offset = 8*/
|
|
uint8_t bSpecific_2; /* Offset = 9*/
|
|
uint8_t abData [ABDATA_SIZE]; /* Offset = 10, For reference, the absolute
|
|
maximum block size for a TPDU T=0 block is 260 bytes
|
|
(5 bytes command; 255 bytes data),
|
|
or for a TPDU T=1 block is 259 bytes,
|
|
or for a short APDU T=1 block is 261 bytes,
|
|
or for an extended APDU T=1 block is 65544 bytes.*/
|
|
} __attribute__((packed, aligned(1))) CCID_bulkin_data_t;
|
|
|
|
typedef struct {
|
|
uint8_t bMessageType; /* Offset = 0*/
|
|
uint32_t dwLength; /* Offset = 1*/
|
|
uint8_t bSlot; /* Offset = 5, Same as Bulk-OUT message */
|
|
uint8_t bSeq; /* Offset = 6, Same as Bulk-OUT message */
|
|
uint8_t bStatus; /* Offset = 7, Slot status as defined in § 6.2.6*/
|
|
uint8_t bError; /* Offset = 8, Slot error as defined in § 6.2.6*/
|
|
uint8_t bSpecific; /* Offset = 9*/
|
|
uint8_t abData[ABDATA_SIZE]; /* Offset = 10*/
|
|
uint16_t u16SizeToSend;
|
|
} __attribute__((packed, aligned(1))) CCID_bulkout_data_t;
|
|
|
|
static const uint8_t atrconst[] = {
|
|
0x3B, 0xDA, 0x11, 0xFF, 0x81, 0xB1, 0xFE, 0x55,
|
|
0x1F, 0x03, 0x00, 0x31, 0x84, 0x73, 0x80, 0x01,
|
|
0x80, 0x00, 0x90, 0x00, 0xE4 };
|
|
|
|
void CCID_UpdateResponseStatus(CCID_bulkout_data_t *pckout, uint8_t status, uint8_t error) {
|
|
pckout->bStatus = status;
|
|
pckout->bError = error;
|
|
};
|
|
|
|
void PC_to_RDR_IccPowerOn(CCID_bulkin_data_t *pckin, CCID_bulkout_data_t *pckout) {
|
|
uint8_t voltage = pckin->bSpecific_0;
|
|
if (voltage >= VOLTS_1_8) {
|
|
/* The Voltage specified is out of Spec */
|
|
CCID_UpdateResponseStatus(pckout, BM_COMMAND_STATUS_FAILED | BM_ICC_PRESENT_ACTIVE, SLOTERROR_BAD_POWERSELECT);
|
|
return;
|
|
}
|
|
|
|
ICCPowered = true;
|
|
ICCStateChanged = true;
|
|
|
|
pckout->dwLength = sizeof(atrconst);
|
|
memmove(pckout->abData, atrconst, sizeof(atrconst));
|
|
|
|
CCID_UpdateResponseStatus(pckout, BM_COMMAND_STATUS_NO_ERROR | BM_ICC_PRESENT_ACTIVE, SLOT_NO_ERROR);
|
|
};
|
|
|
|
void PC_to_RDR_IccPowerOff(CCID_bulkin_data_t *pckin, CCID_bulkout_data_t *pckout) {
|
|
ICCPowered = false;
|
|
ICCStateChanged = true;
|
|
CCID_UpdateResponseStatus(pckout, BM_COMMAND_STATUS_NO_ERROR | BM_ICC_NO_ICC_PRESENT, SLOT_NO_ERROR);
|
|
};
|
|
|
|
void PC_to_RDR_GetSlotStatus(CCID_bulkin_data_t *pckin, CCID_bulkout_data_t *pckout) {
|
|
|
|
CCID_UpdateResponseStatus(pckout, BM_COMMAND_STATUS_NO_ERROR | BM_ICC_PRESENT_ACTIVE, SLOT_NO_ERROR);
|
|
};
|
|
|
|
void PC_to_RDR_XfrBlock(CCID_bulkin_data_t *pckin, CCID_bulkout_data_t *pckout) {
|
|
|
|
size_t len = 0;
|
|
exchange_callback(pckin->abData, pckin->dwLength, pckout->abData, &len);
|
|
pckout->dwLength = len;
|
|
|
|
CCID_UpdateResponseStatus(pckout, BM_COMMAND_STATUS_NO_ERROR | BM_ICC_PRESENT_ACTIVE, SLOT_NO_ERROR);
|
|
};
|
|
|
|
void RDR_to_PC_NotifySlotChange(void) {
|
|
};
|
|
|
|
void RDR_to_PC_SlotStatus(CCID_bulkout_data_t *pckout) {
|
|
pckout->bMessageType = RDR_TO_PC_SLOTSTATUS;
|
|
pckout->dwLength = 0;
|
|
pckout->bSpecific = 0; /* bClockStatus = 00h Clock running
|
|
01h Clock stopped in state L
|
|
02h Clock stopped in state H
|
|
03h Clock stopped in an unknown state
|
|
All other values are RFU. */
|
|
};
|
|
|
|
void RDR_to_PC_DataBlock(CCID_bulkout_data_t *pckout) {
|
|
pckout->bMessageType = RDR_TO_PC_DATABLOCK;
|
|
pckout->bSpecific = 0; /* bChainParameter */
|
|
|
|
// if error - no data send
|
|
if(pckout->bError != SLOT_NO_ERROR) {
|
|
pckout->dwLength = 0;
|
|
}
|
|
};
|
|
|
|
bool ProcessCCIDTransfer(uint8_t *datain, size_t datainlen, uint8_t *dataout, size_t *dataoutlen) {
|
|
|
|
*dataoutlen = 0;
|
|
|
|
if (datainlen < 10)
|
|
return false;
|
|
|
|
#ifdef _DEBUGCLI
|
|
printf("<<<[%ld]: ", datainlen);
|
|
for (size_t i = 0; i < datainlen; i++)
|
|
printf("%02x ",datain[i]);
|
|
printf("\n");
|
|
#endif // _DEBUGCLI
|
|
|
|
CCID_bulkin_data_t *sdatain = (CCID_bulkin_data_t *)datain;
|
|
|
|
if (sdatain->dwLength + CCID_HEADER_SIZE != datainlen)
|
|
return false;
|
|
|
|
// structures vice versa!
|
|
CCID_bulkout_data_t *sdataout = (CCID_bulkout_data_t *)dataout;
|
|
memset(dataout, 0x00, CCID_HEADER_SIZE);
|
|
sdataout->bSlot = sdatain->bSlot;
|
|
sdataout->bSeq = sdatain->bSeq;
|
|
|
|
switch (sdatain->bMessageType) {
|
|
case PC_TO_RDR_ICCPOWERON:
|
|
PC_to_RDR_IccPowerOn(sdatain, sdataout);
|
|
RDR_to_PC_DataBlock(sdataout);
|
|
break;
|
|
case PC_TO_RDR_ICCPOWEROFF:
|
|
PC_to_RDR_IccPowerOff(sdatain, sdataout);
|
|
RDR_to_PC_SlotStatus(sdataout);
|
|
break;
|
|
case PC_TO_RDR_GETSLOTSTATUS:
|
|
PC_to_RDR_GetSlotStatus(sdatain, sdataout);
|
|
RDR_to_PC_SlotStatus(sdataout);
|
|
break;
|
|
case PC_TO_RDR_XFRBLOCK:
|
|
PC_to_RDR_XfrBlock(sdatain, sdataout);
|
|
RDR_to_PC_DataBlock(sdataout);
|
|
break;
|
|
/*
|
|
case PC_TO_RDR_GETPARAMETERS:
|
|
errorCode = PC_to_RDR_GetParameters();
|
|
RDR_to_PC_Parameters(errorCode);
|
|
break;
|
|
case PC_TO_RDR_RESETPARAMETERS:
|
|
errorCode = PC_to_RDR_ResetParameters();
|
|
RDR_to_PC_Parameters(errorCode);
|
|
break;
|
|
case PC_TO_RDR_SETPARAMETERS:
|
|
errorCode = PC_to_RDR_SetParameters();
|
|
RDR_to_PC_Parameters(errorCode);
|
|
break;
|
|
case PC_TO_RDR_ESCAPE:
|
|
errorCode = PC_to_RDR_Escape();
|
|
RDR_to_PC_Escape(errorCode);
|
|
break;
|
|
case PC_TO_RDR_ICCCLOCK:
|
|
errorCode = PC_to_RDR_IccClock();
|
|
RDR_to_PC_SlotStatus(errorCode);
|
|
break;
|
|
case PC_TO_RDR_ABORT:
|
|
errorCode = PC_to_RDR_Abort();
|
|
RDR_to_PC_SlotStatus(errorCode);
|
|
break;
|
|
case PC_TO_RDR_T0APDU:
|
|
errorCode = PC_TO_RDR_T0Apdu();
|
|
RDR_to_PC_SlotStatus(errorCode);
|
|
break;
|
|
case PC_TO_RDR_MECHANICAL:
|
|
errorCode = PC_TO_RDR_Mechanical();
|
|
RDR_to_PC_SlotStatus(errorCode);
|
|
break;
|
|
case PC_TO_RDR_SETDATARATEANDCLOCKFREQUENCY:
|
|
errorCode = PC_TO_RDR_SetDataRateAndClockFrequency();
|
|
RDR_to_PC_DataRateAndClockFrequency(errorCode);
|
|
break;
|
|
case PC_TO_RDR_SECURE:
|
|
errorCode = PC_TO_RDR_Secure();
|
|
RDR_to_PC_DataBlock(errorCode);
|
|
break;
|
|
*/
|
|
default:
|
|
CCID_UpdateResponseStatus(sdataout, BM_COMMAND_STATUS_FAILED | BM_ICC_PRESENT_ACTIVE, SLOTERROR_CMD_NOT_SUPPORTED);
|
|
RDR_to_PC_SlotStatus(sdataout);
|
|
break;
|
|
};
|
|
|
|
*dataoutlen = CCID_HEADER_SIZE + sdataout->dwLength;
|
|
|
|
#ifdef _DEBUGCLI
|
|
printf(">>>[%ld]: ", *dataoutlen);
|
|
for (size_t i = 0; i < *dataoutlen; i++)
|
|
printf("%02x ",dataout[i]);
|
|
printf("\n");
|
|
#endif // _DEBUGCLI
|
|
|
|
return true;
|
|
}
|
|
|