|
Bogdan Timofte
authored
2 months ago
|
1
|
//
|
|
|
2
|
// BTManager.swift
|
|
|
3
|
// USB Meter
|
|
|
4
|
//
|
|
|
5
|
// Created by Bogdan Timofte on 01/03/2020.
|
|
|
6
|
// Copyright © 2020 Bogdan Timofte. All rights reserved.
|
|
|
7
|
//
|
|
|
8
|
|
|
|
9
|
import CoreBluetooth
|
|
Bogdan Timofte
authored
a month ago
|
10
|
import OSLog
|
|
|
11
|
|
|
|
12
|
private let bluetoothDiscoveryLogger = Logger(subsystem: "ro.xdev.USB-Meter", category: "BluetoothDiscovery")
|
|
Bogdan Timofte
authored
2 months ago
|
13
|
|
|
|
14
|
class BluetoothManager : NSObject, ObservableObject {
|
|
Bogdan Timofte
authored
2 months ago
|
15
|
private var manager: CBCentralManager?
|
|
Bogdan Timofte
authored
2 months ago
|
16
|
private var isStarting = false
|
|
Bogdan Timofte
authored
2 months ago
|
17
|
private var advertisementDataCache = AdvertisementDataCache()
|
|
Bogdan Timofte
authored
a month ago
|
18
|
private var lastDiscoveryLog = [String: Date]()
|
|
Bogdan Timofte
authored
2 months ago
|
19
|
@Published var managerState = CBManagerState.unknown
|
|
Bogdan Timofte
authored
2 months ago
|
20
|
@Published private(set) var scanStartedAt: Date?
|
|
Bogdan Timofte
authored
2 months ago
|
21
|
|
|
|
22
|
override init () {
|
|
|
23
|
super.init()
|
|
|
24
|
}
|
|
|
25
|
|
|
Bogdan Timofte
authored
2 months ago
|
26
|
func start() {
|
|
Bogdan Timofte
authored
2 months ago
|
27
|
guard manager == nil, !isStarting else {
|
|
Bogdan Timofte
authored
2 months ago
|
28
|
return
|
|
|
29
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
30
|
isStarting = true
|
|
Bogdan Timofte
authored
2 months ago
|
31
|
track("Starting Bluetooth manager and requesting authorization if needed")
|
|
Bogdan Timofte
authored
2 months ago
|
32
|
DispatchQueue.main.async { [weak self] in
|
|
|
33
|
guard let self else { return }
|
|
|
34
|
defer { self.isStarting = false }
|
|
|
35
|
guard self.manager == nil else {
|
|
|
36
|
return
|
|
|
37
|
}
|
|
|
38
|
self.manager = CBCentralManager(delegate: self, queue: nil)
|
|
|
39
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
40
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
41
|
|
|
|
42
|
private func scanForMeters() {
|
|
Bogdan Timofte
authored
2 months ago
|
43
|
guard let manager else {
|
|
|
44
|
track("Scan requested before Bluetooth manager was started")
|
|
|
45
|
return
|
|
|
46
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
47
|
guard manager.state == .poweredOn else {
|
|
|
48
|
track( "Scan requested but Bluetooth state is \(manager.state)")
|
|
|
49
|
return
|
|
|
50
|
}
|
|
|
51
|
//manager.scanForPeripherals(withServices: allBluetoothRadioServices(), options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
|
|
Bogdan Timofte
authored
a month ago
|
52
|
let serviceUUIDs = allBluetoothRadioServices()
|
|
|
53
|
track("Scanning for USB meters with services: \(serviceUUIDs.map(\.uuidString).joined(separator: ", "))")
|
|
|
54
|
manager.scanForPeripherals(withServices: serviceUUIDs, options: [ CBCentralManagerScanOptionAllowDuplicatesKey: true ])
|
|
Bogdan Timofte
authored
2 months ago
|
55
|
}
|
|
|
56
|
|
|
|
57
|
func discoveredMeter(peripheral: CBPeripheral, advertising advertismentData: [String : Any], rssi RSSI: NSNumber) {
|
|
Bogdan Timofte
authored
a month ago
|
58
|
logDiscoveryCandidate(peripheral: peripheral, advertising: advertismentData, rssi: RSSI)
|
|
|
59
|
let peripheralName = resolvedPeripheralName(for: peripheral, advertising: advertismentData)
|
|
|
60
|
guard let match = resolvedModel(for: peripheralName, advertising: advertismentData) else {
|
|
|
61
|
let reason: String
|
|
|
62
|
if let peripheralName {
|
|
|
63
|
reason = "unrecognized peripheral name '\(peripheralName)'; known names: \(Model.knownPeripheralNames.joined(separator: ", "))"
|
|
|
64
|
} else {
|
|
|
65
|
reason = "missing peripheral name/local name"
|
|
|
66
|
}
|
|
|
67
|
logDiscoveryRejection(
|
|
|
68
|
peripheral: peripheral,
|
|
|
69
|
reason: reason,
|
|
|
70
|
advertising: advertismentData,
|
|
|
71
|
rssi: RSSI
|
|
|
72
|
)
|
|
Bogdan Timofte
authored
2 months ago
|
73
|
return
|
|
|
74
|
}
|
|
Bogdan Timofte
authored
a month ago
|
75
|
let model = match.model
|
|
|
76
|
let radio = match.radio
|
|
|
77
|
let advertisedName = match.advertisedName
|
|
|
78
|
|
|
|
79
|
guard let macAddress = resolvedMACAddress(from: advertismentData) else {
|
|
|
80
|
logDiscoveryRejection(
|
|
|
81
|
peripheral: peripheral,
|
|
|
82
|
reason: "missing or short manufacturer data for '\(advertisedName)'",
|
|
|
83
|
advertising: advertismentData,
|
|
|
84
|
rssi: RSSI
|
|
|
85
|
)
|
|
Bogdan Timofte
authored
2 months ago
|
86
|
return
|
|
|
87
|
}
|
|
|
88
|
|
|
Bogdan Timofte
authored
2 months ago
|
89
|
let macAddressString = macAddress.description
|
|
Bogdan Timofte
authored
a month ago
|
90
|
appData.registerMeter(macAddress: macAddressString, modelName: model.canonicalName, advertisedName: advertisedName)
|
|
Bogdan Timofte
authored
2 months ago
|
91
|
appData.noteMeterSeen(at: Date(), macAddress: macAddressString)
|
|
Bogdan Timofte
authored
2 months ago
|
92
|
|
|
|
93
|
if appData.meters[peripheral.identifier] == nil {
|
|
Bogdan Timofte
authored
a month ago
|
94
|
logDiscovery("BLE discovery accepted: model='\(model.canonicalName)', radio='\(radio)', advertisedName='\(advertisedName)', match='\(match.reason)', macAddress='\(macAddressString)'. \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
|
|
|
95
|
track("adding new USB Meter named '\(advertisedName)' with MAC Address: '\(macAddress)'")
|
|
|
96
|
let btSerial = BluetoothSerial(peripheral: peripheral, radio: radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue)
|
|
Bogdan Timofte
authored
2 months ago
|
97
|
var m = appData.meters
|
|
Bogdan Timofte
authored
a month ago
|
98
|
let meter = Meter(model: model, with: btSerial)
|
|
|
99
|
m[peripheral.identifier] = meter
|
|
Bogdan Timofte
authored
2 months ago
|
100
|
appData.meters = m
|
|
Bogdan Timofte
authored
a month ago
|
101
|
appData.restoreChargeMonitoringStateIfNeeded(for: meter)
|
|
Bogdan Timofte
authored
2 months ago
|
102
|
} else if let meter = appData.meters[peripheral.identifier] {
|
|
|
103
|
meter.lastSeen = Date()
|
|
Bogdan Timofte
authored
2 months ago
|
104
|
meter.btSerial.updateRSSI(RSSI.intValue)
|
|
Bogdan Timofte
authored
2 months ago
|
105
|
let macAddress = meter.btSerial.macAddress.description
|
|
|
106
|
if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
|
|
|
107
|
meter.updateNameFromStore(syncedName)
|
|
|
108
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
109
|
if peripheral.delegate == nil {
|
|
|
110
|
peripheral.delegate = meter.btSerial
|
|
|
111
|
}
|
|
|
112
|
}
|
|
|
113
|
}
|
|
Bogdan Timofte
authored
a month ago
|
114
|
|
|
|
115
|
private func resolvedModel(for peripheralName: String?, advertising advertismentData: [String: Any]) -> (model: Model, advertisedName: String, radio: BluetoothRadio, reason: String)? {
|
|
|
116
|
if let peripheralName {
|
|
|
117
|
if let model = Model.model(forPeripheralName: peripheralName) {
|
|
|
118
|
return (model, peripheralName, radio(for: model, peripheralName: peripheralName), "recognized peripheral name")
|
|
|
119
|
}
|
|
|
120
|
}
|
|
|
121
|
|
|
|
122
|
return nil
|
|
|
123
|
}
|
|
|
124
|
|
|
|
125
|
private func radio(for model: Model, peripheralName: String) -> BluetoothRadio {
|
|
|
126
|
guard model == .TC66C else {
|
|
|
127
|
return model.radio
|
|
|
128
|
}
|
|
|
129
|
|
|
|
130
|
if peripheralName.caseInsensitiveCompare("BT24-M") == .orderedSame {
|
|
|
131
|
return .BT24M
|
|
|
132
|
}
|
|
|
133
|
|
|
|
134
|
return model.radio
|
|
|
135
|
}
|
|
|
136
|
|
|
|
137
|
private func resolvedMACAddress(from advertismentData: [String: Any]) -> MACAddress? {
|
|
|
138
|
guard let manufacturerData = resolvedManufacturerData(from: advertismentData), manufacturerData.count > 2 else {
|
|
|
139
|
return nil
|
|
|
140
|
}
|
|
|
141
|
return MACAddress(from: manufacturerData.suffix(from: 2))
|
|
|
142
|
}
|
|
|
143
|
|
|
|
144
|
private func logDiscoveryCandidate(peripheral: CBPeripheral, advertising advertismentData: [String: Any], rssi RSSI: NSNumber) {
|
|
|
145
|
guard shouldLogDiscoveryDetails(for: peripheral.identifier) else { return }
|
|
|
146
|
logDiscovery("BLE discovery candidate: \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
|
|
|
147
|
}
|
|
|
148
|
|
|
|
149
|
private func logDiscoveryRejection(
|
|
|
150
|
peripheral: CBPeripheral,
|
|
|
151
|
reason: String,
|
|
|
152
|
advertising advertismentData: [String: Any],
|
|
|
153
|
rssi RSSI: NSNumber
|
|
|
154
|
) {
|
|
|
155
|
guard shouldLogDiscoveryRejection(for: peripheral.identifier, reason: reason) else { return }
|
|
|
156
|
logDiscovery("BLE discovery rejected: \(reason). \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
|
|
|
157
|
}
|
|
|
158
|
|
|
|
159
|
private func logDiscovery(_ message: String) {
|
|
|
160
|
track(message)
|
|
|
161
|
bluetoothDiscoveryLogger.notice("\(message, privacy: .public)")
|
|
|
162
|
}
|
|
|
163
|
|
|
|
164
|
private func shouldLogDiscoveryDetails(for identifier: UUID) -> Bool {
|
|
|
165
|
guard ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" else {
|
|
|
166
|
return false
|
|
|
167
|
}
|
|
|
168
|
return shouldLogDiscoveryDetails(for: identifier.uuidString)
|
|
|
169
|
}
|
|
|
170
|
|
|
|
171
|
private func shouldLogDiscoveryRejection(for identifier: UUID, reason: String) -> Bool {
|
|
|
172
|
shouldLogDiscoveryDetails(for: "\(identifier.uuidString):\(reason)")
|
|
|
173
|
}
|
|
|
174
|
|
|
|
175
|
private func shouldLogDiscoveryDetails(for key: String) -> Bool {
|
|
|
176
|
let now = Date()
|
|
|
177
|
if let lastLoggedAt = lastDiscoveryLog[key], now.timeIntervalSince(lastLoggedAt) < 5 {
|
|
|
178
|
return false
|
|
|
179
|
}
|
|
|
180
|
lastDiscoveryLog[key] = now
|
|
|
181
|
return true
|
|
|
182
|
}
|
|
|
183
|
|
|
|
184
|
private func discoveryDescription(peripheral: CBPeripheral, advertising advertismentData: [String: Any], rssi RSSI: NSNumber) -> String {
|
|
|
185
|
let localName = advertismentData[CBAdvertisementDataLocalNameKey] as? String
|
|
|
186
|
let services = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
|
|
|
187
|
let overflowServices = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataOverflowServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
|
|
|
188
|
let solicitedServices = serviceUUIDs(from: advertismentData, key: CBAdvertisementDataSolicitedServiceUUIDsKey).map(\.uuidString).joined(separator: ", ")
|
|
|
189
|
let manufacturerData = resolvedManufacturerData(from: advertismentData)
|
|
|
190
|
let manufacturerSummary = manufacturerData.map { "\($0.count)b \($0.hexEncodedStringValue)" } ?? "nil"
|
|
|
191
|
let txPower = advertismentData[CBAdvertisementDataTxPowerLevelKey].map { "\($0)" } ?? "nil"
|
|
|
192
|
let connectable = advertismentData[CBAdvertisementDataIsConnectable].map { "\($0)" } ?? "nil"
|
|
|
193
|
|
|
|
194
|
return "id='\(peripheral.identifier)', peripheralName='\(peripheral.name ?? "nil")', localName='\(localName ?? "nil")', resolvedName='\(resolvedPeripheralName(for: peripheral, advertising: advertismentData) ?? "nil")', rssi=\(RSSI), connectable=\(connectable), txPower=\(txPower), services=[\(services)], overflowServices=[\(overflowServices)], solicitedServices=[\(solicitedServices)], manufacturerData=\(manufacturerSummary)"
|
|
|
195
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
196
|
|
|
|
197
|
private func resolvedPeripheralName(for peripheral: CBPeripheral, advertising advertismentData: [String : Any]) -> String? {
|
|
|
198
|
let candidates = [
|
|
|
199
|
(advertismentData[CBAdvertisementDataLocalNameKey] as? String),
|
|
|
200
|
peripheral.name
|
|
|
201
|
]
|
|
|
202
|
|
|
|
203
|
for candidate in candidates {
|
|
|
204
|
if let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty {
|
|
|
205
|
return trimmed
|
|
Bogdan Timofte
authored
2 months ago
|
206
|
}
|
|
|
207
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
208
|
|
|
|
209
|
return nil
|
|
|
210
|
}
|
|
Bogdan Timofte
authored
a month ago
|
211
|
|
|
|
212
|
private func serviceUUIDs(from advertismentData: [String : Any], key: String) -> [CBUUID] {
|
|
|
213
|
advertismentData[key] as? [CBUUID] ?? []
|
|
|
214
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
215
|
|
|
|
216
|
private func resolvedManufacturerData(from advertismentData: [String : Any]) -> Data? {
|
|
|
217
|
if let data = advertismentData[CBAdvertisementDataManufacturerDataKey] as? Data {
|
|
|
218
|
return data
|
|
Bogdan Timofte
authored
2 months ago
|
219
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
220
|
if let data = advertismentData["kCBAdvDataManufacturerData"] as? Data {
|
|
|
221
|
return data
|
|
|
222
|
}
|
|
|
223
|
return nil
|
|
Bogdan Timofte
authored
2 months ago
|
224
|
}
|
|
|
225
|
}
|
|
|
226
|
|
|
|
227
|
extension BluetoothManager : CBCentralManagerDelegate {
|
|
|
228
|
// MARK: CBCentralManager state Changed
|
|
|
229
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
|
|
230
|
managerState = central.state;
|
|
|
231
|
track("\(central.state)")
|
|
Bogdan Timofte
authored
2 months ago
|
232
|
for meter in appData.meters.values {
|
|
|
233
|
meter.btSerial.centralStateChanged(to: central.state)
|
|
|
234
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
235
|
|
|
|
236
|
switch central.state {
|
|
|
237
|
case .poweredOff:
|
|
Bogdan Timofte
authored
2 months ago
|
238
|
scanStartedAt = nil
|
|
Bogdan Timofte
authored
2 months ago
|
239
|
advertisementDataCache.clear()
|
|
Bogdan Timofte
authored
2 months ago
|
240
|
track("Bluetooth is Off. How should I behave?")
|
|
|
241
|
case .poweredOn:
|
|
Bogdan Timofte
authored
2 months ago
|
242
|
scanStartedAt = Date()
|
|
Bogdan Timofte
authored
2 months ago
|
243
|
track("Bluetooth is On... Start scanning...")
|
|
|
244
|
// note that "didDisconnectPeripheral" won't be called if BLE is turned off while connected
|
|
|
245
|
// connectedPeripheral = nil
|
|
|
246
|
// pendingPeripheral = nil
|
|
Bogdan Timofte
authored
2 months ago
|
247
|
scanForMeters()
|
|
Bogdan Timofte
authored
2 months ago
|
248
|
case .resetting:
|
|
Bogdan Timofte
authored
2 months ago
|
249
|
scanStartedAt = nil
|
|
Bogdan Timofte
authored
2 months ago
|
250
|
advertisementDataCache.clear()
|
|
Bogdan Timofte
authored
2 months ago
|
251
|
track("Bluetooth is reseting... . Whatever that means.")
|
|
|
252
|
case .unauthorized:
|
|
Bogdan Timofte
authored
2 months ago
|
253
|
scanStartedAt = nil
|
|
Bogdan Timofte
authored
2 months ago
|
254
|
advertisementDataCache.clear()
|
|
Bogdan Timofte
authored
2 months ago
|
255
|
track("Bluetooth is not authorized.")
|
|
|
256
|
case .unknown:
|
|
Bogdan Timofte
authored
2 months ago
|
257
|
scanStartedAt = nil
|
|
Bogdan Timofte
authored
2 months ago
|
258
|
advertisementDataCache.clear()
|
|
Bogdan Timofte
authored
2 months ago
|
259
|
track("Bluetooth is in an unknown state.")
|
|
|
260
|
case .unsupported:
|
|
Bogdan Timofte
authored
2 months ago
|
261
|
scanStartedAt = nil
|
|
Bogdan Timofte
authored
2 months ago
|
262
|
advertisementDataCache.clear()
|
|
Bogdan Timofte
authored
2 months ago
|
263
|
track("Bluetooth not supported by device")
|
|
|
264
|
default:
|
|
Bogdan Timofte
authored
2 months ago
|
265
|
scanStartedAt = nil
|
|
Bogdan Timofte
authored
2 months ago
|
266
|
advertisementDataCache.clear()
|
|
Bogdan Timofte
authored
2 months ago
|
267
|
track("Bluetooth is in a state never seen before!")
|
|
|
268
|
}
|
|
|
269
|
}
|
|
|
270
|
|
|
|
271
|
// MARK: CBCentralManager didDiscover peripheral
|
|
|
272
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
|
|
|
273
|
let completeAdvertisementData = self.advertisementDataCache.append(peripheral: peripheral, advertisementData: advertisementData)
|
|
|
274
|
//track("Device discoverded UUID: '\(peripheral.identifier)' named '\(peripheral.name ?? "Unknown")'); RSSI: \(RSSI) dBm; Advertisment data: \(advertisementData)")
|
|
|
275
|
discoveredMeter(peripheral: peripheral, advertising: completeAdvertisementData, rssi: RSSI )
|
|
|
276
|
}
|
|
|
277
|
|
|
|
278
|
// MARK: CBCentralManager didConnect peripheral
|
|
|
279
|
internal func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
|
280
|
//track("Connected to peripheral: '\(peripheral.identifier)'")
|
|
|
281
|
if let usbMeter = appData.meters[peripheral.identifier] {
|
|
|
282
|
usbMeter.btSerial.connectionEstablished()
|
|
|
283
|
}
|
|
|
284
|
else {
|
|
Bogdan Timofte
authored
2 months ago
|
285
|
track("Connected to meter with UUID: '\(peripheral.identifier)'")
|
|
Bogdan Timofte
authored
2 months ago
|
286
|
}
|
|
|
287
|
}
|
|
|
288
|
|
|
|
289
|
// MARK: CBCentralManager didDisconnectPeripheral peripheral
|
|
|
290
|
internal func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
|
|
291
|
track("Disconnected from peripheral: '\(peripheral.identifier)' with error: \(error.debugDescription)")
|
|
|
292
|
if let usbMeter = appData.meters[peripheral.identifier] {
|
|
|
293
|
usbMeter.btSerial.connectionClosed()
|
|
|
294
|
}
|
|
|
295
|
else {
|
|
Bogdan Timofte
authored
2 months ago
|
296
|
track("Disconnected from meter with UUID: '\(peripheral.identifier)'")
|
|
Bogdan Timofte
authored
2 months ago
|
297
|
}
|
|
|
298
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
299
|
|
|
|
300
|
internal func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
|
301
|
track("Failed to connect to peripheral: '\(peripheral.identifier)' with error: \(error.debugDescription)")
|
|
|
302
|
if let usbMeter = appData.meters[peripheral.identifier] {
|
|
|
303
|
usbMeter.btSerial.connectionClosed()
|
|
|
304
|
} else {
|
|
Bogdan Timofte
authored
2 months ago
|
305
|
track("Failed to connect to meter with UUID: '\(peripheral.identifier)'")
|
|
Bogdan Timofte
authored
2 months ago
|
306
|
}
|
|
|
307
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
308
|
}
|
|
|
309
|
|
|
|
310
|
private class AdvertisementDataCache {
|
|
|
311
|
|
|
Bogdan Timofte
authored
2 months ago
|
312
|
private var map = [UUID: [String: Any]]()
|
|
Bogdan Timofte
authored
2 months ago
|
313
|
|
|
|
314
|
func append(peripheral: CBPeripheral, advertisementData: [String: Any]) -> [String: Any] {
|
|
|
315
|
var ad = (map[peripheral.identifier]) ?? [String: Any]()
|
|
Bogdan Timofte
authored
2 months ago
|
316
|
if let localName = peripheral.name?.trimmingCharacters(in: .whitespacesAndNewlines), !localName.isEmpty {
|
|
|
317
|
ad[CBAdvertisementDataLocalNameKey] = localName
|
|
|
318
|
}
|
|
Bogdan Timofte
authored
2 months ago
|
319
|
for (key, value) in advertisementData {
|
|
|
320
|
ad[key] = value
|
|
|
321
|
}
|
|
|
322
|
map[peripheral.identifier] = ad
|
|
|
323
|
return ad
|
|
|
324
|
}
|
|
|
325
|
|
|
|
326
|
func clear() {
|
|
|
327
|
map.removeAll()
|
|
|
328
|
}
|
|
|
329
|
}
|