1/****************************************************************************
2**
3** Copyright (C) 2016 The Qt Company Ltd.
4** Contact: https://www.qt.io/licensing/
5**
6** This file is part of the QtNetwork module of the Qt Toolkit.
7**
8** $QT_BEGIN_LICENSE:LGPL$
9** Commercial License Usage
10** Licensees holding valid commercial Qt licenses may use this file in
11** accordance with the commercial license agreement provided with the
12** Software or, alternatively, in accordance with the terms contained in
13** a written agreement between you and The Qt Company. For licensing terms
14** and conditions see https://www.qt.io/terms-conditions. For further
15** information use the contact form at https://www.qt.io/contact-us.
16**
17** GNU Lesser General Public License Usage
18** Alternatively, this file may be used under the terms of the GNU Lesser
19** General Public License version 3 as published by the Free Software
20** Foundation and appearing in the file LICENSE.LGPL3 included in the
21** packaging of this file. Please review the following information to
22** ensure the GNU Lesser General Public License version 3 requirements
23** will be met: https://www.gnu.org/licenses/lgpl-3.0.html.
24**
25** GNU General Public License Usage
26** Alternatively, this file may be used under the terms of the GNU
27** General Public License version 2.0 or (at your option) the GNU General
28** Public license version 3 or any later version approved by the KDE Free
29** Qt Foundation. The licenses are as published by the Free Software
30** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3
31** included in the packaging of this file. Please review the following
32** information to ensure the GNU General Public License requirements will
33** be met: https://www.gnu.org/licenses/gpl-2.0.html and
34** https://www.gnu.org/licenses/gpl-3.0.html.
35**
36** $QT_END_LICENSE$
37**
38****************************************************************************/
39
40//#define QNETWORKDISKCACHE_DEBUG
41
42
43#include "qnetworkdiskcache.h"
44#include "qnetworkdiskcache_p.h"
45#include "QtCore/qscopedpointer.h"
46
47#include <qfile.h>
48#include <qdir.h>
49#include <qdatastream.h>
50#include <qdatetime.h>
51#include <qdiriterator.h>
52#include <qurl.h>
53#include <qcryptographichash.h>
54#include <qdebug.h>
55
56#define CACHE_POSTFIX QLatin1String(".d")
57#define PREPARED_SLASH QLatin1String("prepared/")
58#define CACHE_VERSION 8
59#define DATA_DIR QLatin1String("data")
60
61#define MAX_COMPRESSION_SIZE (1024 * 1024 * 3)
62
63QT_BEGIN_NAMESPACE
64
65/*!
66 \class QNetworkDiskCache
67 \since 4.5
68 \inmodule QtNetwork
69
70 \brief The QNetworkDiskCache class provides a very basic disk cache.
71
72 QNetworkDiskCache stores each url in its own file inside of the
73 cacheDirectory using QDataStream. Files with a text MimeType
74 are compressed using qCompress. Data is written to disk only in insert()
75 and updateMetaData().
76
77 Currently you cannot share the same cache files with more than
78 one disk cache.
79
80 QNetworkDiskCache by default limits the amount of space that the cache will
81 use on the system to 50MB.
82
83 Note you have to set the cache directory before it will work.
84
85 A network disk cache can be enabled by:
86
87 \snippet code/src_network_access_qnetworkdiskcache.cpp 0
88
89 When sending requests, to control the preference of when to use the cache
90 and when to use the network, consider the following:
91
92 \snippet code/src_network_access_qnetworkdiskcache.cpp 1
93
94 To check whether the response came from the cache or from the network, the
95 following can be applied:
96
97 \snippet code/src_network_access_qnetworkdiskcache.cpp 2
98*/
99
100/*!
101 Creates a new disk cache. The \a parent argument is passed to
102 QAbstractNetworkCache's constructor.
103 */
104QNetworkDiskCache::QNetworkDiskCache(QObject *parent)
105 : QAbstractNetworkCache(*new QNetworkDiskCachePrivate, parent)
106{
107}
108
109/*!
110 Destroys the cache object. This does not clear the disk cache.
111 */
112QNetworkDiskCache::~QNetworkDiskCache()
113{
114 Q_D(QNetworkDiskCache);
115 qDeleteAll(d->inserting);
116}
117
118/*!
119 Returns the location where cached files will be stored.
120*/
121QString QNetworkDiskCache::cacheDirectory() const
122{
123 Q_D(const QNetworkDiskCache);
124 return d->cacheDirectory;
125}
126
127/*!
128 Sets the directory where cached files will be stored to \a cacheDir
129
130 QNetworkDiskCache will create this directory if it does not exists.
131
132 Prepared cache items will be stored in the new cache directory when
133 they are inserted.
134
135 \sa QStandardPaths::CacheLocation
136*/
137void QNetworkDiskCache::setCacheDirectory(const QString &cacheDir)
138{
139#if defined(QNETWORKDISKCACHE_DEBUG)
140 qDebug() << "QNetworkDiskCache::setCacheDirectory()" << cacheDir;
141#endif
142 Q_D(QNetworkDiskCache);
143 if (cacheDir.isEmpty())
144 return;
145 d->cacheDirectory = cacheDir;
146 QDir dir(d->cacheDirectory);
147 d->cacheDirectory = dir.absolutePath();
148 if (!d->cacheDirectory.endsWith(QLatin1Char('/')))
149 d->cacheDirectory += QLatin1Char('/');
150
151 d->dataDirectory = d->cacheDirectory + DATA_DIR + QString::number(CACHE_VERSION) + QLatin1Char('/');
152 d->prepareLayout();
153}
154
155/*!
156 \reimp
157*/
158qint64 QNetworkDiskCache::cacheSize() const
159{
160#if defined(QNETWORKDISKCACHE_DEBUG)
161 qDebug("QNetworkDiskCache::cacheSize()");
162#endif
163 Q_D(const QNetworkDiskCache);
164 if (d->cacheDirectory.isEmpty())
165 return 0;
166 if (d->currentCacheSize < 0) {
167 QNetworkDiskCache *that = const_cast<QNetworkDiskCache*>(this);
168 that->d_func()->currentCacheSize = that->expire();
169 }
170 return d->currentCacheSize;
171}
172
173/*!
174 \reimp
175*/
176QIODevice *QNetworkDiskCache::prepare(const QNetworkCacheMetaData &metaData)
177{
178#if defined(QNETWORKDISKCACHE_DEBUG)
179 qDebug() << "QNetworkDiskCache::prepare()" << metaData.url();
180#endif
181 Q_D(QNetworkDiskCache);
182 if (!metaData.isValid() || !metaData.url().isValid() || !metaData.saveToDisk())
183 return nullptr;
184
185 if (d->cacheDirectory.isEmpty()) {
186 qWarning("QNetworkDiskCache::prepare() The cache directory is not set");
187 return nullptr;
188 }
189
190 const auto headers = metaData.rawHeaders();
191 for (const auto &header : headers) {
192 if (header.first.compare("content-length", Qt::CaseInsensitive) == 0) {
193 const qint64 size = header.second.toLongLong();
194 if (size > (maximumCacheSize() * 3)/4)
195 return nullptr;
196 break;
197 }
198 }
199 QScopedPointer<QCacheItem> cacheItem(new QCacheItem);
200 cacheItem->metaData = metaData;
201
202 QIODevice *device = nullptr;
203 if (cacheItem->canCompress()) {
204 cacheItem->data.open(QBuffer::ReadWrite);
205 device = &(cacheItem->data);
206 } else {
207 QString templateName = d->tmpCacheFileName();
208 QT_TRY {
209 cacheItem->file = new QTemporaryFile(templateName, &cacheItem->data);
210 } QT_CATCH(...) {
211 cacheItem->file = nullptr;
212 }
213 if (!cacheItem->file || !cacheItem->file->open()) {
214 qWarning("QNetworkDiskCache::prepare() unable to open temporary file");
215 cacheItem.reset();
216 return nullptr;
217 }
218 cacheItem->writeHeader(cacheItem->file);
219 device = cacheItem->file;
220 }
221 d->inserting[device] = cacheItem.take();
222 return device;
223}
224
225/*!
226 \reimp
227*/
228void QNetworkDiskCache::insert(QIODevice *device)
229{
230#if defined(QNETWORKDISKCACHE_DEBUG)
231 qDebug() << "QNetworkDiskCache::insert()" << device;
232#endif
233 Q_D(QNetworkDiskCache);
234 const auto it = d->inserting.constFind(device);
235 if (Q_UNLIKELY(it == d->inserting.cend())) {
236 qWarning() << "QNetworkDiskCache::insert() called on a device we don't know about" << device;
237 return;
238 }
239
240 d->storeItem(it.value());
241 delete it.value();
242 d->inserting.erase(it);
243}
244
245
246/*!
247 Create subdirectories and other housekeeping on the filesystem.
248 Prevents too many files from being present in any single directory.
249*/
250void QNetworkDiskCachePrivate::prepareLayout()
251{
252 QDir helper;
253 helper.mkpath(cacheDirectory + PREPARED_SLASH);
254
255 //Create directory and subdirectories 0-F
256 helper.mkpath(dataDirectory);
257 for (uint i = 0; i < 16 ; i++) {
258 QString str = QString::number(i, 16);
259 QString subdir = dataDirectory + str;
260 helper.mkdir(subdir);
261 }
262}
263
264
265void QNetworkDiskCachePrivate::storeItem(QCacheItem *cacheItem)
266{
267 Q_Q(QNetworkDiskCache);
268 Q_ASSERT(cacheItem->metaData.saveToDisk());
269
270 QString fileName = cacheFileName(cacheItem->metaData.url());
271 Q_ASSERT(!fileName.isEmpty());
272
273 if (QFile::exists(fileName)) {
274 if (!QFile::remove(fileName)) {
275 qWarning() << "QNetworkDiskCache: couldn't remove the cache file " << fileName;
276 return;
277 }
278 }
279
280 if (currentCacheSize > 0)
281 currentCacheSize += 1024 + cacheItem->size();
282 currentCacheSize = q->expire();
283 if (!cacheItem->file) {
284 QString templateName = tmpCacheFileName();
285 cacheItem->file = new QTemporaryFile(templateName, &cacheItem->data);
286 if (cacheItem->file->open()) {
287 cacheItem->writeHeader(cacheItem->file);
288 cacheItem->writeCompressedData(cacheItem->file);
289 }
290 }
291
292 if (cacheItem->file
293 && cacheItem->file->isOpen()
294 && cacheItem->file->error() == QFile::NoError) {
295 cacheItem->file->setAutoRemove(false);
296 // ### use atomic rename rather then remove & rename
297 if (cacheItem->file->rename(fileName))
298 currentCacheSize += cacheItem->file->size();
299 else
300 cacheItem->file->setAutoRemove(true);
301 }
302 if (cacheItem->metaData.url() == lastItem.metaData.url())
303 lastItem.reset();
304}
305
306/*!
307 \reimp
308*/
309bool QNetworkDiskCache::remove(const QUrl &url)
310{
311#if defined(QNETWORKDISKCACHE_DEBUG)
312 qDebug() << "QNetworkDiskCache::remove()" << url;
313#endif
314 Q_D(QNetworkDiskCache);
315
316 // remove is also used to cancel insertions, not a common operation
317 for (auto it = d->inserting.cbegin(), end = d->inserting.cend(); it != end; ++it) {
318 QCacheItem *item = it.value();
319 if (item && item->metaData.url() == url) {
320 delete item;
321 d->inserting.erase(it);
322 return true;
323 }
324 }
325
326 if (d->lastItem.metaData.url() == url)
327 d->lastItem.reset();
328 return d->removeFile(d->cacheFileName(url));
329}
330
331/*!
332 Put all of the misc file removing into one function to be extra safe
333 */
334bool QNetworkDiskCachePrivate::removeFile(const QString &file)
335{
336#if defined(QNETWORKDISKCACHE_DEBUG)
337 qDebug() << "QNetworkDiskCache::removFile()" << file;
338#endif
339 if (file.isEmpty())
340 return false;
341 QFileInfo info(file);
342 QString fileName = info.fileName();
343 if (!fileName.endsWith(CACHE_POSTFIX))
344 return false;
345 qint64 size = info.size();
346 if (QFile::remove(file)) {
347 currentCacheSize -= size;
348 return true;
349 }
350 return false;
351}
352
353/*!
354 \reimp
355*/
356QNetworkCacheMetaData QNetworkDiskCache::metaData(const QUrl &url)
357{
358#if defined(QNETWORKDISKCACHE_DEBUG)
359 qDebug() << "QNetworkDiskCache::metaData()" << url;
360#endif
361 Q_D(QNetworkDiskCache);
362 if (d->lastItem.metaData.url() == url)
363 return d->lastItem.metaData;
364 return fileMetaData(d->cacheFileName(url));
365}
366
367/*!
368 Returns the QNetworkCacheMetaData for the cache file \a fileName.
369
370 If \a fileName is not a cache file QNetworkCacheMetaData will be invalid.
371 */
372QNetworkCacheMetaData QNetworkDiskCache::fileMetaData(const QString &fileName) const
373{
374#if defined(QNETWORKDISKCACHE_DEBUG)
375 qDebug() << "QNetworkDiskCache::fileMetaData()" << fileName;
376#endif
377 Q_D(const QNetworkDiskCache);
378 QFile file(fileName);
379 if (!file.open(QFile::ReadOnly))
380 return QNetworkCacheMetaData();
381 if (!d->lastItem.read(&file, false)) {
382 file.close();
383 QNetworkDiskCachePrivate *that = const_cast<QNetworkDiskCachePrivate*>(d);
384 that->removeFile(fileName);
385 }
386 return d->lastItem.metaData;
387}
388
389/*!
390 \reimp
391*/
392QIODevice *QNetworkDiskCache::data(const QUrl &url)
393{
394#if defined(QNETWORKDISKCACHE_DEBUG)
395 qDebug() << "QNetworkDiskCache::data()" << url;
396#endif
397 Q_D(QNetworkDiskCache);
398 QScopedPointer<QBuffer> buffer;
399 if (!url.isValid())
400 return nullptr;
401 if (d->lastItem.metaData.url() == url && d->lastItem.data.isOpen()) {
402 buffer.reset(new QBuffer);
403 buffer->setData(d->lastItem.data.data());
404 } else {
405 QScopedPointer<QFile> file(new QFile(d->cacheFileName(url)));
406 if (!file->open(QFile::ReadOnly | QIODevice::Unbuffered))
407 return nullptr;
408
409 if (!d->lastItem.read(file.data(), true)) {
410 file->close();
411 remove(url);
412 return nullptr;
413 }
414 if (d->lastItem.data.isOpen()) {
415 // compressed
416 buffer.reset(new QBuffer);
417 buffer->setData(d->lastItem.data.data());
418 } else {
419 buffer.reset(new QBuffer);
420 // ### verify that QFile uses the fd size and not the file name
421 qint64 size = file->size() - file->pos();
422 const uchar *p = nullptr;
423#if !defined(Q_OS_INTEGRITY)
424 p = file->map(file->pos(), size);
425#endif
426 if (p) {
427 buffer->setData((const char *)p, size);
428 file.take()->setParent(buffer.data());
429 } else {
430 buffer->setData(file->readAll());
431 }
432 }
433 }
434 buffer->open(QBuffer::ReadOnly);
435 return buffer.take();
436}
437
438/*!
439 \reimp
440*/
441void QNetworkDiskCache::updateMetaData(const QNetworkCacheMetaData &metaData)
442{
443#if defined(QNETWORKDISKCACHE_DEBUG)
444 qDebug() << "QNetworkDiskCache::updateMetaData()" << metaData.url();
445#endif
446 QUrl url = metaData.url();
447 QIODevice *oldDevice = data(url);
448 if (!oldDevice) {
449#if defined(QNETWORKDISKCACHE_DEBUG)
450 qDebug("QNetworkDiskCache::updateMetaData(), no device!");
451#endif
452 return;
453 }
454
455 QIODevice *newDevice = prepare(metaData);
456 if (!newDevice) {
457#if defined(QNETWORKDISKCACHE_DEBUG)
458 qDebug() << "QNetworkDiskCache::updateMetaData(), no new device!" << url;
459#endif
460 return;
461 }
462 char data[1024];
463 while (!oldDevice->atEnd()) {
464 qint64 s = oldDevice->read(data, 1024);
465 newDevice->write(data, s);
466 }
467 delete oldDevice;
468 insert(newDevice);
469}
470
471/*!
472 Returns the current maximum size for the disk cache.
473
474 \sa setMaximumCacheSize()
475 */
476qint64 QNetworkDiskCache::maximumCacheSize() const
477{
478 Q_D(const QNetworkDiskCache);
479 return d->maximumCacheSize;
480}
481
482/*!
483 Sets the maximum size of the disk cache to be \a size.
484
485 If the new size is smaller then the current cache size then the cache will call expire().
486
487 \sa maximumCacheSize()
488 */
489void QNetworkDiskCache::setMaximumCacheSize(qint64 size)
490{
491 Q_D(QNetworkDiskCache);
492 bool expireCache = (size < d->maximumCacheSize);
493 d->maximumCacheSize = size;
494 if (expireCache)
495 d->currentCacheSize = expire();
496}
497
498/*!
499 Cleans the cache so that its size is under the maximum cache size.
500 Returns the current size of the cache.
501
502 When the current size of the cache is greater than the maximumCacheSize()
503 older cache files are removed until the total size is less then 90% of
504 maximumCacheSize() starting with the oldest ones first using the file
505 creation date to determine how old a cache file is.
506
507 Subclasses can reimplement this function to change the order that cache
508 files are removed taking into account information in the application
509 knows about that QNetworkDiskCache does not, for example the number of times
510 a cache is accessed.
511
512 \note cacheSize() calls expire if the current cache size is unknown.
513
514 \sa maximumCacheSize(), fileMetaData()
515 */
516qint64 QNetworkDiskCache::expire()
517{
518 Q_D(QNetworkDiskCache);
519 if (d->currentCacheSize >= 0 && d->currentCacheSize < maximumCacheSize())
520 return d->currentCacheSize;
521
522 if (cacheDirectory().isEmpty()) {
523 qWarning("QNetworkDiskCache::expire() The cache directory is not set");
524 return 0;
525 }
526
527 // close file handle to prevent "in use" error when QFile::remove() is called
528 d->lastItem.reset();
529
530 QDir::Filters filters = QDir::AllDirs | QDir:: Files | QDir::NoDotAndDotDot;
531 QDirIterator it(cacheDirectory(), filters, QDirIterator::Subdirectories);
532
533 QMultiMap<QDateTime, QString> cacheItems;
534 qint64 totalSize = 0;
535 while (it.hasNext()) {
536 QString path = it.next();
537 QFileInfo info = it.fileInfo();
538 QString fileName = info.fileName();
539 if (fileName.endsWith(CACHE_POSTFIX)) {
540 const QDateTime birthTime = info.fileTime(QFile::FileBirthTime);
541 cacheItems.insert(birthTime.isValid() ? birthTime
542 : info.fileTime(QFile::FileMetadataChangeTime), path);
543 totalSize += info.size();
544 }
545 }
546
547 int removedFiles = 0;
548 qint64 goal = (maximumCacheSize() * 9) / 10;
549 QMultiMap<QDateTime, QString>::const_iterator i = cacheItems.constBegin();
550 while (i != cacheItems.constEnd()) {
551 if (totalSize < goal)
552 break;
553 QString name = i.value();
554 QFile file(name);
555
556 if (name.contains(PREPARED_SLASH)) {
557 for (QCacheItem *item : qAsConst(d->inserting)) {
558 if (item && item->file && item->file->fileName() == name) {
559 delete item->file;
560 item->file = nullptr;
561 break;
562 }
563 }
564 }
565
566 qint64 size = file.size();
567 file.remove();
568 totalSize -= size;
569 ++removedFiles;
570 ++i;
571 }
572#if defined(QNETWORKDISKCACHE_DEBUG)
573 if (removedFiles > 0) {
574 qDebug() << "QNetworkDiskCache::expire()"
575 << "Removed:" << removedFiles
576 << "Kept:" << cacheItems.count() - removedFiles;
577 }
578#endif
579 return totalSize;
580}
581
582/*!
583 \reimp
584*/
585void QNetworkDiskCache::clear()
586{
587#if defined(QNETWORKDISKCACHE_DEBUG)
588 qDebug("QNetworkDiskCache::clear()");
589#endif
590 Q_D(QNetworkDiskCache);
591 qint64 size = d->maximumCacheSize;
592 d->maximumCacheSize = 0;
593 d->currentCacheSize = expire();
594 d->maximumCacheSize = size;
595}
596
597/*!
598 Given a URL, generates a unique enough filename (and subdirectory)
599 */
600QString QNetworkDiskCachePrivate::uniqueFileName(const QUrl &url)
601{
602 QUrl cleanUrl = url;
603 cleanUrl.setPassword(QString());
604 cleanUrl.setFragment(QString());
605
606 QCryptographicHash hash(QCryptographicHash::Sha1);
607 hash.addData(cleanUrl.toEncoded());
608 // convert sha1 to base36 form and return first 8 bytes for use as string
609 const QByteArray id = QByteArray::number(*(qlonglong*)hash.result().constData(), 36).left(8);
610 // generates <one-char subdir>/<8-char filname.d>
611 uint code = (uint)id.at(id.length()-1) % 16;
612 QString pathFragment = QString::number(code, 16) + QLatin1Char('/')
613 + QLatin1String(id) + CACHE_POSTFIX;
614
615 return pathFragment;
616}
617
618QString QNetworkDiskCachePrivate::tmpCacheFileName() const
619{
620 //The subdirectory is presumed to be already read for use.
621 return cacheDirectory + PREPARED_SLASH + QLatin1String("XXXXXX") + CACHE_POSTFIX;
622}
623
624/*!
625 Generates fully qualified path of cached resource from a URL.
626 */
627QString QNetworkDiskCachePrivate::cacheFileName(const QUrl &url) const
628{
629 if (!url.isValid())
630 return QString();
631
632 QString fullpath = dataDirectory + uniqueFileName(url);
633 return fullpath;
634}
635
636/*!
637 We compress small text and JavaScript files.
638 */
639bool QCacheItem::canCompress() const
640{
641 bool sizeOk = false;
642 bool typeOk = false;
643 const auto headers = metaData.rawHeaders();
644 for (const auto &header : headers) {
645 if (header.first.compare("content-length", Qt::CaseInsensitive) == 0) {
646 qint64 size = header.second.toLongLong();
647 if (size > MAX_COMPRESSION_SIZE)
648 return false;
649 else
650 sizeOk = true;
651 }
652
653 if (header.first.compare("content-type", Qt::CaseInsensitive) == 0) {
654 QByteArray type = header.second;
655 if (type.startsWith("text/")
656 || (type.startsWith("application/")
657 && (type.endsWith("javascript") || type.endsWith("ecmascript"))))
658 typeOk = true;
659 else
660 return false;
661 }
662 if (sizeOk && typeOk)
663 return true;
664 }
665 return false;
666}
667
668enum
669{
670 CacheMagic = 0xe8,
671 CurrentCacheVersion = CACHE_VERSION
672};
673
674void QCacheItem::writeHeader(QFile *device) const
675{
676 QDataStream out(device);
677
678 out << qint32(CacheMagic);
679 out << qint32(CurrentCacheVersion);
680 out << static_cast<qint32>(out.version());
681 out << metaData;
682 bool compressed = canCompress();
683 out << compressed;
684}
685
686void QCacheItem::writeCompressedData(QFile *device) const
687{
688 QDataStream out(device);
689
690 out << qCompress(data.data());
691}
692
693/*!
694 Returns \c false if the file is a cache file,
695 but is an older version and should be removed otherwise true.
696 */
697bool QCacheItem::read(QFile *device, bool readData)
698{
699 reset();
700
701 QDataStream in(device);
702
703 qint32 marker;
704 qint32 v;
705 in >> marker;
706 in >> v;
707 if (marker != CacheMagic)
708 return true;
709
710 // If the cache magic is correct, but the version is not we should remove it
711 if (v != CurrentCacheVersion)
712 return false;
713
714 qint32 streamVersion;
715 in >> streamVersion;
716 // Default stream version is also the highest we can handle
717 if (streamVersion > in.version())
718 return false;
719 in.setVersion(streamVersion);
720
721 bool compressed;
722 QByteArray dataBA;
723 in >> metaData;
724 in >> compressed;
725 if (readData && compressed) {
726 in >> dataBA;
727 data.setData(qUncompress(dataBA));
728 data.open(QBuffer::ReadOnly);
729 }
730
731 // quick and dirty check if metadata's URL field and the file's name are in synch
732 QString expectedFilename = QNetworkDiskCachePrivate::uniqueFileName(metaData.url());
733 if (!device->fileName().endsWith(expectedFilename))
734 return false;
735
736 return metaData.isValid();
737}
738
739QT_END_NAMESPACE
740