Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
QtAudioDeviceManager.java
Go to the documentation of this file.
1// Copyright (C) 2021 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3
4package org.qtproject.qt.android.multimedia;
5
6import java.util.ArrayList;
7import java.util.Arrays;
8import java.util.Comparator;
9import java.util.HashMap;
10import java.util.List;
11import java.util.concurrent.CountDownLatch;
12import java.util.concurrent.atomic.AtomicInteger;
13import android.content.Context;
14import android.media.AudioDeviceCallback;
15import android.media.AudioDeviceInfo;
16import android.media.AudioFormat;
17import android.media.AudioManager;
18import android.media.AudioRecord;
19import android.media.AudioTrack;
20import android.media.MediaRecorder;
21import android.os.Build;
22import android.os.Handler;
23import android.os.Looper;
24import android.util.Log;
25import android.util.Range;
26
27import org.qtproject.qt.android.UsedFromNativeCode;
28
29class QtAudioDeviceManager
30{
31 private static final String TAG = "QtAudioDeviceManager";
32
33 static private AudioManager m_audioManager = null;
34 static private final AudioDevicesReceiver m_audioDevicesReceiver = new AudioDevicesReceiver();
35 static private Handler handler = new Handler(Looper.getMainLooper());
36 static private AudioRecord m_recorder = null;
37 static private AudioTrack m_streamPlayer = null;
38 static private Thread m_streamingThread = null;
39 static private boolean m_isStreaming = false;
40 static private boolean m_useSpeaker = false;
41 static private final int m_sampleRate = 8000;
42 static private final int m_channels = AudioFormat.CHANNEL_CONFIGURATION_MONO;
43 static private final int m_audioFormat = AudioFormat.ENCODING_PCM_16BIT;
44 static private final int m_bufferSize = AudioRecord.getMinBufferSize(m_sampleRate, m_channels, m_audioFormat);
45 static private int m_currentOutputId = -1;
46 static private AtomicInteger m_scoCounter = new AtomicInteger();
47 static private AtomicInteger m_communicationDeviceCounter = new AtomicInteger();
48 static private AtomicInteger m_speakerphoneCounter = new AtomicInteger();
49 static private int m_currentCommunicationDeviceId = -1;
50
51 // Holds the pointer to the QAndroidAudioDevices instance.
52 //
53 // This is always assigned with callbacks registered, from a
54 // C++ thread. Assignment only happens when QAndroidAudioDevices
55 // is constructed. It is always cleared from the Handler thread, using
56 // blocking job from the C++ thread.
57 static private long m_qAudioDevicesNativePtr = 0;
58
59 static native void onAudioInputDevicesUpdated(long qAudioDeviceNativePtr);
60 static native void onAudioOutputDevicesUpdated(long qAudioDeviceNativePtr);
61
62 static private void updateDeviceList() {
63 assert(handler.getLooper().isCurrentThread());
64 // Make sure we never get a callback when we are unregistered.
65 assert(m_qAudioDevicesNativePtr != 0);
66
67 if (m_currentOutputId != -1)
68 prepareAudioOutput(m_currentOutputId);
69 onAudioInputDevicesUpdated(m_qAudioDevicesNativePtr);
70 onAudioOutputDevicesUpdated(m_qAudioDevicesNativePtr);
71 }
72
73 private static class AudioDevicesReceiver extends AudioDeviceCallback {
75 public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
76 assert(handler.getLooper().isCurrentThread());
77 updateDeviceList();
78 }
79
81 public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
82 assert(handler.getLooper().isCurrentThread());
83 updateDeviceList();
84 }
85 }
86
87 @UsedFromNativeCode
88 static void qAndroidAudioDevicesConstructed(long nativePtr)
89 {
90 assert(nativePtr != 0);
91 m_qAudioDevicesNativePtr = nativePtr;
92 m_audioManager.registerAudioDeviceCallback(m_audioDevicesReceiver, handler);
93 }
94
95 @UsedFromNativeCode
96 static void qAndroidAudioDevicesDestroyed()
97 {
98 assert(!handler.getLooper().isCurrentThread());
99
100 // Perform cleanup on the same thread that we receive callbacks, then wait for cleanup job
101 // to finish. No need for locks.
102 final CountDownLatch latch = new CountDownLatch(1);
103 final boolean postSuccess = handler.post(() -> {
104 m_audioManager.unregisterAudioDeviceCallback(m_audioDevicesReceiver);
105 assert(m_qAudioDevicesNativePtr != 0);
106 m_qAudioDevicesNativePtr = 0;
107 latch.countDown(); // Signal that the task is done
108 });
109
110 if (!postSuccess) {
111 Log.w(
112 TAG,
113 "Unable to post cleanup job to corresponding thread during " +
114 "QAndroidAudioDevices cleanup.");
115 return;
116 }
117
118 try {
119 latch.await();
120 } catch (Exception e) {
121 Log.w(
122 TAG,
123 "Unable to wait for cleanup job to finish on corresponding thread during" +
124 "QAndroidAudioDevices cleanup.");
125 }
126
127 // After waiting, QAndroidAudioDevices can now be safely destroyed.
128 }
129
130 static void setContext(Context context)
131 {
132 m_audioManager = (AudioManager)context.getSystemService(Context.AUDIO_SERVICE);
133 }
134
135 static AudioDeviceInfo[] getAudioOutputDevices()
136 {
137 return getAudioDevices(AudioManager.GET_DEVICES_OUTPUTS);
138 }
139
140 static AudioDeviceInfo[] getAudioInputDevices()
141 {
142 return getAudioDevices(AudioManager.GET_DEVICES_INPUTS);
143 }
144
145 @UsedFromNativeCode
146 static AudioDeviceInfo getAudioInputDeviceInfo(int id)
147 {
148 final AudioDeviceInfo[] audioDevices = m_audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS);
149
150 for (AudioDeviceInfo deviceInfo : audioDevices) {
151 if (deviceInfo.getId() == id)
152 return deviceInfo;
153 }
154
155 return null;
156 }
157
158 @UsedFromNativeCode
159 static int getDefaultSampleRate()
160 {
161 String sampleRate = m_audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
162 return Integer.parseInt(sampleRate);
163 }
164
165 @UsedFromNativeCode
166 static boolean isBluetoothDevice(AudioDeviceInfo deviceInfo)
167 {
168 switch (deviceInfo.getType()) {
169 case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
170 case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
171 return true;
172 default:
173 return false;
174 }
175 }
176
177 @UsedFromNativeCode
178 static boolean prepareAudioInput(int id)
179 {
180 final AudioDeviceInfo[] audioDevices =
181 m_audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS);
182 for (AudioDeviceInfo deviceInfo : audioDevices) {
183 if (deviceInfo.getId() == id) {
184 switch (deviceInfo.getType())
185 {
186 case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
187 case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
188 case AudioDeviceInfo.TYPE_WIRED_HEADSET:
189 case AudioDeviceInfo.TYPE_USB_HEADSET:
190 case AudioDeviceInfo.TYPE_BUILTIN_MIC:
191 return prepareAudioDevice(deviceInfo, AudioManager.MODE_NORMAL);
192 default:
193 return true;
194 }
195 }
196 }
197 return false;
198 }
199
200 private static void setInputMuted(boolean mute)
201 {
202 // This method mutes the microphone across the entire platform
203 m_audioManager.setMicrophoneMute(mute);
204 }
205
206 private static boolean isMicrophoneMute()
207 {
208 return m_audioManager.isMicrophoneMute();
209 }
210
211 private static String audioDeviceTypeToString(int type)
212 {
213 // API <= 23 types
214 switch (type)
215 {
216 case AudioDeviceInfo.TYPE_AUX_LINE:
217 return "AUX Line";
218 case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
219 case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
220 return "Bluetooth";
221 case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
222 return "Built in earpiece";
223 case AudioDeviceInfo.TYPE_BUILTIN_MIC:
224 return "Built in microphone";
225 case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
226 return "Built in speaker";
227 case AudioDeviceInfo.TYPE_DOCK:
228 return "Dock";
229 case AudioDeviceInfo.TYPE_FM:
230 return "FM";
231 case AudioDeviceInfo.TYPE_FM_TUNER:
232 return "FM TUNER";
233 case AudioDeviceInfo.TYPE_HDMI:
234 return "HDMI";
235 case AudioDeviceInfo.TYPE_HDMI_ARC:
236 return "HDMI ARC";
237 case AudioDeviceInfo.TYPE_IP:
238 return "IP";
239 case AudioDeviceInfo.TYPE_LINE_ANALOG:
240 return "Line analog";
241 case AudioDeviceInfo.TYPE_LINE_DIGITAL:
242 return "Line digital";
243 case AudioDeviceInfo.TYPE_TV_TUNER:
244 return "TV tuner";
245 case AudioDeviceInfo.TYPE_USB_ACCESSORY:
246 return "USB accessory";
247 case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
248 return "Wired headphones";
249 case AudioDeviceInfo.TYPE_WIRED_HEADSET:
250 return "Wired headset";
251 }
252
253 // API 24
254 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
255 if (type == AudioDeviceInfo.TYPE_BUS)
256 return "Bus";
257 }
258
259 return "Unknown-Type";
260
261 }
262
263 private static final HashMap<Integer, Integer> priorityMap = new HashMap<Integer, Integer>() {{
264 put(AudioDeviceInfo.TYPE_WIRED_HEADSET, 1);
265 put(AudioDeviceInfo.TYPE_WIRED_HEADPHONES, 1);
266 put(AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, 2);
267 put(AudioDeviceInfo.TYPE_BLUETOOTH_SCO, 2);
268 put(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, 3);
269 }};
270 private static final int DEFAULT_PRIORITY = 4;
271
272 private static void sortAudioDevices(AudioDeviceInfo[] devices) {
273 Comparator<AudioDeviceInfo> deviceTypeComparator = new Comparator<AudioDeviceInfo>() {
274 @Override
275 public int compare(AudioDeviceInfo device1, AudioDeviceInfo device2) {
276 return getPriority(device1) - getPriority(device2);
277 }
278
279 private int getPriority(AudioDeviceInfo device) {
280 return priorityMap.getOrDefault(device.getType(), DEFAULT_PRIORITY);
281 }
282 };
283
284 Arrays.sort(devices, deviceTypeComparator);
285 }
286
287 private static AudioDeviceInfo[] getAudioDevices(int type)
288 {
289 ArrayList<AudioDeviceInfo> filteredDevices = new ArrayList<>();
290
291 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
292 boolean builtInMicAdded = false;
293 boolean bluetoothDeviceAdded = false;
294 AudioDeviceInfo[] audioDevices = m_audioManager.getDevices(type);
295 sortAudioDevices(audioDevices);
296
297 for (AudioDeviceInfo deviceInfo : audioDevices) {
298 String deviceType = audioDeviceTypeToString(deviceInfo.getType());
299
300 if (deviceType.equals(audioDeviceTypeToString(AudioDeviceInfo.TYPE_UNKNOWN))) {
301 // Not supported device type
302 continue;
303 } else if (deviceType.equals(audioDeviceTypeToString(AudioDeviceInfo.TYPE_BUILTIN_MIC))) {
304 if (builtInMicAdded) {
305 // Built in mic already added. Second built in mic is CAMCORDER, but there
306 // is no reliable way of selecting it. AudioSource.MIC usually means the
307 // primary mic. AudioSource.CAMCORDER source might mean the secondary mic,
308 // but there's no guarantee. It depends e.g. on the physical placement
309 // of the mics. That's why we will not add built in microphone twice.
310 // Should we?
311 continue;
312 }
313 builtInMicAdded = true;
314 } else if (isBluetoothDevice(deviceInfo)) {
315 if (bluetoothDeviceAdded) {
316 // Bluetooth device already added. Second device is just a different
317 // technology profille (like A2DP or SCO). We should not add the same
318 // device twice. Should we?
319 continue;
320 }
321 bluetoothDeviceAdded = true;
322 }
323
324 filteredDevices.add(deviceInfo);
325 }
326 }
327
328 return filteredDevices.toArray(new AudioDeviceInfo[filteredDevices.size()]);
329 }
330
331 final private static int [] bluetoothTypes = {
332 AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
333 AudioDeviceInfo.TYPE_BLUETOOTH_SCO
334 };
335 final private static int [] wiredTypes = {
336 AudioDeviceInfo.TYPE_WIRED_HEADSET,
337 AudioDeviceInfo.TYPE_WIRED_HEADPHONES
338 };
339
340 private static boolean containsAnyOfType(AudioDeviceInfo[] devices, int[]... types) {
341
342 return Arrays.stream(devices)
343 .anyMatch(device -> Arrays.stream(types)
344 .flatMapToInt(Arrays::stream)
345 .anyMatch(type -> device.getType() == type));
346 }
347
348 private static int getCorrectModeIfContainsAnyOfType(AudioDeviceInfo[] devices, int[]... types) {
349 return containsAnyOfType(devices, types) ?
350 AudioManager.MODE_IN_COMMUNICATION : AudioManager.MODE_NORMAL;
351 }
352
353 private static int getModeForWired(AudioDeviceInfo[] audioDevices)
354 {
355 return getCorrectModeIfContainsAnyOfType(audioDevices, bluetoothTypes);
356 }
357
358 private static int getModeForBluetooth(AudioDeviceInfo[] audioDevices)
359 {
360 return getCorrectModeIfContainsAnyOfType(audioDevices, wiredTypes);
361 }
362
363 private static int getModeForSpeaker(AudioDeviceInfo[] audioDevices)
364 {
365 return getCorrectModeIfContainsAnyOfType(audioDevices, bluetoothTypes, wiredTypes);
366 }
367
368 private static boolean prepareAudioOutput(int id)
369 {
370 final AudioDeviceInfo[] audioDevices =
371 m_audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS);
372 for (AudioDeviceInfo deviceInfo : audioDevices) {
373 if (deviceInfo.getId() == id) {
374 switch (deviceInfo.getType())
375 {
376 case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
377 case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
378 return prepareAudioDevice(deviceInfo, getModeForBluetooth(audioDevices));
379 case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER:
380 return prepareAudioDevice(deviceInfo, getModeForSpeaker(audioDevices));
381 case AudioDeviceInfo.TYPE_WIRED_HEADSET:
382 case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
383 return prepareAudioDevice(deviceInfo, getModeForWired(audioDevices));
384 case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
385 // It doesn't work when WIRED HEADPHONES are connected
386 // Earpiece has the lowest priority and setWiredHeadsetOn(boolean)
387 // method to force it is deprecated
388 Log.w(TAG, "Built in Earpiece may not work when "
389 + "Wired Headphones are connected");
390 return prepareAudioDevice(deviceInfo, AudioManager.MODE_IN_CALL);
391 case AudioDeviceInfo.TYPE_HDMI:
392 case AudioDeviceInfo.TYPE_HDMI_ARC:
393 case AudioDeviceInfo.TYPE_HDMI_EARC:
394 return prepareAudioDevice(deviceInfo, AudioManager.MODE_NORMAL);
395 default:
396 return false;
397 }
398 }
399 }
400 return false;
401 }
402
407 private static AudioDeviceInfo getValidCommunicationDevice(AudioDeviceInfo device) {
408 if (device.isSink())
409 return device;
410
411 if (isBluetoothDevice(device)) {
412 // For Bluetooth sources, get output device with the same type and address
413 List<AudioDeviceInfo> communicationDevices = m_audioManager.getAvailableCommunicationDevices();
414 for (AudioDeviceInfo communicationDevice : communicationDevices) {
415 boolean isSameType = communicationDevice.getType() == device.getType();
416 boolean isSameAddress = communicationDevice.getAddress().equals(device.getAddress());
417 if (isSameType && isSameAddress)
418 return communicationDevice;
419 }
420
421 Log.w(TAG, "No matching bluetooth output device found for " + device);
422 }
423
424 return null;
425 }
426
427 private static boolean deviceIsCurrentCommunicationDevice(AudioDeviceInfo device) {
428 if (m_currentCommunicationDeviceId == -1 || device == null) {
429 return false;
430 }
431
432 return m_currentCommunicationDeviceId == device.getId();
433 }
434
435 @UsedFromNativeCode
436 static void releaseAudioDevice(int id) {
437 final AudioDeviceInfo[] devices = m_audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
439 if (device.getId() == id)
440 releaseAudioDevice(device);
441 }
442 }
443
447 private static void releaseAudioDevice(AudioDeviceInfo deviceInfo) {
448 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
449 // If device was used as the communication device, it should be cleared
450 AudioDeviceInfo communicationDevice = getValidCommunicationDevice(deviceInfo);
451 if (!deviceIsCurrentCommunicationDevice(communicationDevice))
452 return;
453
454 if (m_communicationDeviceCounter.decrementAndGet() == 0) {
455 m_audioManager.clearCommunicationDevice();
456 m_currentCommunicationDeviceId = -1;
457 }
458 } else if (isBluetoothDevice(deviceInfo) && m_audioManager.isBluetoothScoOn()
459 && m_scoCounter.decrementAndGet() == 0) {
460 m_audioManager.stopBluetoothSco();
461 m_audioManager.setBluetoothScoOn(false);
462 } else if (deviceInfo.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
463 && m_speakerphoneCounter.decrementAndGet() == 0) {
464 m_audioManager.setSpeakerphoneOn(false);
465 }
466 }
467
468 private static boolean prepareAudioDevice(AudioDeviceInfo deviceInfo, int mode)
469 {
470 if (deviceInfo == null)
471 return false;
472
473 m_audioManager.setMode(mode);
474
475 boolean isSink = deviceInfo.isSink();
476 if (isSink)
477 m_currentOutputId = deviceInfo.getId();
478
479 boolean isBluetoothDevice = isBluetoothDevice(deviceInfo);
480 boolean isBluetoothSource = isBluetoothDevice && !isSink;
481 boolean isCommunicationMode = (mode == AudioManager.MODE_IN_CALL
482 || mode == AudioManager.MODE_IN_COMMUNICATION);
483
484 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
485 if (!isBluetoothSource && !isCommunicationMode)
486 return true;
487
488 // For communication modes and Bluetooth sources, it's required to set a communication device
489 AudioDeviceInfo communicationDevice = getValidCommunicationDevice(deviceInfo);
490 if (communicationDevice == null) {
491 Log.w(TAG, "No suitable communication device to set to enable communication via "
492 + deviceInfo.getId());
493 return false;
494 }
495
496 if (deviceIsCurrentCommunicationDevice(communicationDevice)) {
497 m_communicationDeviceCounter.incrementAndGet();
498 return true;
499 } else if (m_audioManager.setCommunicationDevice(communicationDevice)) {
500 // NOTE: Keep track of communication devices we set, as it takes time for it to be
501 // fully operational.
502 // TODO: Other applications can set a different communication device, in which case
503 // we should probably register a listener and clear our tracking when the
504 // communication device unexpectedly changes
505 m_currentCommunicationDeviceId = communicationDevice.getId();
506 m_communicationDeviceCounter.set(1);
507 return true;
508 }
509
510 return false;
511 }
512
513 boolean isSpeakerphoneDevice = deviceInfo.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER;
514
515 if (isBluetoothSource && m_scoCounter.getAndIncrement() == 0) {
516 m_audioManager.startBluetoothSco();
517 m_audioManager.setBluetoothScoOn(true);
518 } else if (isSpeakerphoneDevice
519 && m_speakerphoneCounter.getAndIncrement() == 0) {
520 // TODO: Check if setting speakerphone is actually required for anything, it's not
521 // recommended in Android docs.
522 m_audioManager.setSpeakerphoneOn(true);
523 }
524
525 return true;
526 }
527
528 private static void streamSound()
529 {
530 byte data[] = new byte[m_bufferSize];
531 while (m_isStreaming) {
532 m_recorder.read(data, 0, m_bufferSize);
533 m_streamPlayer.play();
534 m_streamPlayer.write(data, 0, m_bufferSize);
535 m_streamPlayer.stop();
536 }
537 }
538
539 private static void startSoundStreaming(int inputId, int outputId)
540 {
541 if (m_isStreaming)
542 stopSoundStreaming();
543
544 m_recorder = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, m_sampleRate, m_channels,
545 m_audioFormat, m_bufferSize);
546 m_streamPlayer = new AudioTrack(AudioManager.STREAM_MUSIC, m_sampleRate, m_channels,
547 m_audioFormat, m_bufferSize, AudioTrack.MODE_STREAM);
548
549 final AudioDeviceInfo[] devices = m_audioManager.getDevices(AudioManager.GET_DEVICES_ALL);
550 for (AudioDeviceInfo deviceInfo : devices) {
551 if (deviceInfo.getId() == outputId) {
552 m_streamPlayer.setPreferredDevice(deviceInfo);
553 } else if (deviceInfo.getId() == inputId) {
554 m_recorder.setPreferredDevice(deviceInfo);
555 }
556 }
557
558 m_recorder.startRecording();
559 m_isStreaming = true;
560
561 m_streamingThread = new Thread(new Runnable() {
562 @Override
563 public void run() {
564 streamSound();
565 }
566 });
567
568 m_streamingThread.start();
569 }
570
571 private static void stopSoundStreaming()
572 {
573 if (!m_isStreaming)
574 return;
575
576 m_isStreaming = false;
577 try {
578 m_streamingThread.join();
579 m_streamingThread = null;
580 } catch (InterruptedException e) {
581 e.printStackTrace();
582 }
583 m_recorder.stop();
584 m_recorder.release();
585 m_streamPlayer.release();
586 m_streamPlayer = null;
587 m_recorder = null;
588 }
589}
void AudioDeviceInfo()
[Audio callback capture setup peak meter]
Definition audio.cpp:278
IOBluetoothDevice * device
QPainter Context
static const QString context()
Definition java.cpp:396
Catch::Generators::GeneratorWrapper< T > handler(Catch::Generators::GeneratorWrapper< T > &&generator)
Returns a generator wrapping generator that ensures that changes its semantics so that the first call...
QTCONCURRENT_RUN_NODISCARD auto run(QThreadPool *pool, Function &&f, Args &&...args)
#define assert
EGLDeviceEXT * devices
GLenum mode
GLsizei GLenum GLenum * types
GLenum type
GLint GLsizei GLsizei GLenum GLenum GLsizei void * data
[0]
@ Handler
static int compare(quint64 a, quint64 b)
static QInputDevice::DeviceType deviceType(const UINT cursorType)
manager put(request, myData, this, [this](QRestReply &reply) { if(reply.isSuccess()) })
[5]