diff --git a/app/schemas/com.hippo.ehviewer.dao.EhDatabase/13.json b/app/schemas/com.hippo.ehviewer.dao.EhDatabase/13.json new file mode 100644 index 000000000..07d771bbd --- /dev/null +++ b/app/schemas/com.hippo.ehviewer.dao.EhDatabase/13.json @@ -0,0 +1,469 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "92915e937cb7ccc536d352fda5cf287f", + "entities": [ + { + "tableName": "DOWNLOADS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, `POSITION` INTEGER NOT NULL DEFAULT 0, `GID` INTEGER NOT NULL, `TOKEN` TEXT, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_LANGUAGE` TEXT, PRIMARY KEY(`GID`))", + "fields": [ + { + "fieldPath": "state", + "columnName": "STATE", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "legacy", + "columnName": "LEGACY", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "TIME", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "LABEL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "POSITION", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "gid", + "columnName": "GID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "TOKEN", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "TITLE", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "titleJpn", + "columnName": "TITLE_JPN", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbKey", + "columnName": "THUMB", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "CATEGORY", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "posted", + "columnName": "POSTED", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "UPLOADER", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rating", + "columnName": "RATING", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "simpleLanguage", + "columnName": "SIMPLE_LANGUAGE", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "GID" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DOWNLOAD_LABELS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `TIME` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL DEFAULT 0, `_id` INTEGER, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "label", + "columnName": "LABEL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "TIME", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "POSITION", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DOWNLOAD_DIRNAME", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))", + "fields": [ + { + "fieldPath": "gid", + "columnName": "GID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dirname", + "columnName": "DIRNAME", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "GID" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FILTER", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "mode", + "columnName": "MODE", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "TEXT", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "ENABLE", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "HISTORY", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`TIME` INTEGER NOT NULL, `FAVORITE_SLOT` INTEGER NOT NULL, `GID` INTEGER NOT NULL, `TOKEN` TEXT, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_LANGUAGE` TEXT, PRIMARY KEY(`GID`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "TIME", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favoriteSlot", + "columnName": "FAVORITE_SLOT", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gid", + "columnName": "GID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "TOKEN", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "TITLE", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "titleJpn", + "columnName": "TITLE_JPN", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbKey", + "columnName": "THUMB", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "CATEGORY", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "posted", + "columnName": "POSTED", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "UPLOADER", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rating", + "columnName": "RATING", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "simpleLanguage", + "columnName": "SIMPLE_LANGUAGE", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "GID" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LOCAL_FAVORITES", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`TIME` INTEGER NOT NULL, `GID` INTEGER NOT NULL, `TOKEN` TEXT, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_LANGUAGE` TEXT, PRIMARY KEY(`GID`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "TIME", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gid", + "columnName": "GID", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "TOKEN", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "TITLE", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "titleJpn", + "columnName": "TITLE_JPN", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbKey", + "columnName": "THUMB", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "category", + "columnName": "CATEGORY", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "posted", + "columnName": "POSTED", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "UPLOADER", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "rating", + "columnName": "RATING", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "simpleLanguage", + "columnName": "SIMPLE_LANGUAGE", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "GID" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "QUICK_SEARCH", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "NAME", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "MODE", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "CATEGORY", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "KEYWORD", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "advanceSearch", + "columnName": "ADVANCE_SEARCH", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minRating", + "columnName": "MIN_RATING", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageFrom", + "columnName": "PAGE_FROM", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageTo", + "columnName": "PAGE_TO", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "TIME", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "POSITION", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '92915e937cb7ccc536d352fda5cf287f')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/hippo/ehviewer/EhDB.kt b/app/src/main/java/com/hippo/ehviewer/EhDB.kt index d664b4e19..04a070e2d 100644 --- a/app/src/main/java/com/hippo/ehviewer/EhDB.kt +++ b/app/src/main/java/com/hippo/ehviewer/EhDB.kt @@ -61,7 +61,15 @@ object EhDB { } suspend fun removeDownloadInfo(downloadInfo: DownloadInfo) { - db.downloadsDao().delete(downloadInfo) + val dao = db.downloadsDao() + dao.delete(downloadInfo) + dao.fill(downloadInfo.position) + } + + suspend fun removeDownloadInfo(downloadInfo: List) { + val dao = db.downloadsDao() + dao.delete(downloadInfo) + dao.fill(downloadInfo.first().position, downloadInfo.size) } suspend fun getDownloadDirname(gid: Long): String? { diff --git a/app/src/main/java/com/hippo/ehviewer/dao/Database.kt b/app/src/main/java/com/hippo/ehviewer/dao/Database.kt index 333cc7d4d..0c9c72b9b 100644 --- a/app/src/main/java/com/hippo/ehviewer/dao/Database.kt +++ b/app/src/main/java/com/hippo/ehviewer/dao/Database.kt @@ -31,6 +31,12 @@ class DownloadLabelMigration : AutoMigrationSpec { } } +class DownloadMigration : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE DOWNLOADS SET POSITION = (SELECT COUNT(*) FROM DOWNLOADS T WHERE T.TIME < DOWNLOADS.TIME)") + } +} + class ThumbKeyMigration : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { val needMigrationTables = arrayOf( @@ -53,7 +59,7 @@ class ThumbKeyMigration : AutoMigrationSpec { @Database( entities = [DownloadInfo::class, DownloadLabel::class, DownloadDirname::class, Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, QuickSearch::class], - version = 12, + version = 13, exportSchema = true, autoMigrations = [ AutoMigration( @@ -93,6 +99,11 @@ class ThumbKeyMigration : AutoMigrationSpec { to = 12, spec = DownloadLabelMigration::class, ), + AutoMigration( + from = 12, + to = 13, + spec = DownloadMigration::class, + ), ], ) @TypeConverters(FilterModeConverter::class) diff --git a/app/src/main/java/com/hippo/ehviewer/dao/DownloadInfo.kt b/app/src/main/java/com/hippo/ehviewer/dao/DownloadInfo.kt index 55df7e675..8f34a351b 100644 --- a/app/src/main/java/com/hippo/ehviewer/dao/DownloadInfo.kt +++ b/app/src/main/java/com/hippo/ehviewer/dao/DownloadInfo.kt @@ -20,6 +20,9 @@ class DownloadInfo() : BaseGalleryInfo() { @ColumnInfo(name = "LABEL") var label: String? = null + @ColumnInfo(name = "POSITION", defaultValue = "0") + var position: Int = 0 + @Ignore var speed: Long = 0 diff --git a/app/src/main/java/com/hippo/ehviewer/dao/DownloadsDao.kt b/app/src/main/java/com/hippo/ehviewer/dao/DownloadsDao.kt index bd7838f7d..102d8c594 100644 --- a/app/src/main/java/com/hippo/ehviewer/dao/DownloadsDao.kt +++ b/app/src/main/java/com/hippo/ehviewer/dao/DownloadsDao.kt @@ -8,15 +8,18 @@ import androidx.room.Update @Dao interface DownloadsDao { - @Query("SELECT * FROM DOWNLOADS ORDER BY TIME DESC") + @Query("SELECT * FROM DOWNLOADS ORDER BY POSITION DESC") suspend fun list(): List - @Query("SELECT * FROM DOWNLOADS ORDER BY TIME DESC LIMIT :limit OFFSET :offset") + @Query("SELECT * FROM DOWNLOADS ORDER BY POSITION DESC LIMIT :limit OFFSET :offset") suspend fun list(offset: Int, limit: Int): List @Query("SELECT * FROM DOWNLOADS WHERE GID = :gid") suspend fun load(gid: Long): DownloadInfo? + @Query("UPDATE DOWNLOADS SET POSITION = POSITION - :offset WHERE POSITION > :position") + suspend fun fill(position: Int, offset: Int = 1) + @Update suspend fun update(downloadInfo: List) @@ -28,4 +31,7 @@ interface DownloadsDao { @Delete suspend fun delete(downloadInfo: DownloadInfo) + + @Delete + suspend fun delete(downloadInfo: List) } diff --git a/app/src/main/java/com/hippo/ehviewer/download/DownloadManager.kt b/app/src/main/java/com/hippo/ehviewer/download/DownloadManager.kt index 9e3ecdd56..432c18377 100644 --- a/app/src/main/java/com/hippo/ehviewer/download/DownloadManager.kt +++ b/app/src/main/java/com/hippo/ehviewer/download/DownloadManager.kt @@ -207,6 +207,7 @@ object DownloadManager : OnSpiderListener { info.label = label info.state = DownloadInfo.STATE_WAIT info.time = System.currentTimeMillis() + info.position = allInfoList.size // Add to label download list val list = getInfoListForLabel(info.label) @@ -326,33 +327,21 @@ object DownloadManager : OnSpiderListener { } suspend fun addDownload(downloadInfoList: List, notify: Boolean = true) { - for (info in downloadInfoList) { - if (containDownloadInfo(info.gid)) { - // Contain - return - } - + downloadInfoList.filterNot { containDownloadInfo(it.gid) }.forEach { info -> // Ensure download state if (DownloadInfo.STATE_WAIT == info.state || DownloadInfo.STATE_DOWNLOAD == info.state ) { info.state = DownloadInfo.STATE_NONE } + info.position = allInfoList.size // Add to label download list - var list = getInfoListForLabel(info.label) - if (null == list) { - // Can't find the label in label list - list = LinkedList() - map[info.label] = list - if (!containLabel(info.label)) { - // Add label to DB and list - labelList.add(EhDB.addDownloadLabel(DownloadLabel(info.label!!, position = labelList.size))) - } - } + val list = getInfoListForLabel(info.label) + ?: addLabel(info.label).let { getInfoListForLabel(info.label)!! } list.add(info) // Sort - list.sortByDateDescending() + list.sortByPositionDescending() // Add to all download list and map allInfoList.add(info) @@ -363,7 +352,7 @@ object DownloadManager : OnSpiderListener { } // Sort all download list - allInfoList.sortByDateDescending() + allInfoList.sortByPositionDescending() // Notify if (notify) { @@ -393,6 +382,7 @@ object DownloadManager : OnSpiderListener { info.label = label info.state = DownloadInfo.STATE_NONE info.time = System.currentTimeMillis() + info.position = allInfoList.size // Add to label download list val list = getInfoListForLabel(info.label) @@ -508,25 +498,11 @@ object DownloadManager : OnSpiderListener { suspend fun deleteRangeDownload(gidList: LongList) { stopRangeDownloadInternal(gidList) - for (i in 0 until gidList.size) { - val gid = gidList[i] - val info = mAllInfoMap[gid] - if (null == info) { - Log.d(TAG, "Can't get download info with gid: $gid") - continue - } - - // Remove from DB - EhDB.removeDownloadInfo(info) - - // Remove from all info map - allInfoList.remove(info) - mAllInfoMap.remove(info.gid) - - // Remove from label list - val list = getInfoListForLabel(info.label) - list?.remove(info) + val list = gidList.mapNotNull { gid -> + mAllInfoMap.remove(gid)?.also { getInfoListForLabel(it.label)?.remove(it) } } + EhDB.removeDownloadInfo(list) + allInfoList.removeAll(list.toSet()) // Update listener for (l in mDownloadInfoListeners) { @@ -537,36 +513,24 @@ object DownloadManager : OnSpiderListener { ensureDownload() } - suspend fun moveDownload(fromPosition: Int, toPosition: Int) { - if (fromPosition > toPosition) { - val time = allInfoList[toPosition].time - for (i in toPosition until fromPosition) { - allInfoList[i].time = allInfoList[i + 1].time - } - allInfoList[fromPosition].time = time - EhDB.updateDownloadInfo(allInfoList.slice(toPosition..fromPosition)) - } else { - val time = allInfoList[fromPosition].time - for (i in fromPosition until toPosition) { - allInfoList[i].time = allInfoList[i + 1].time - } - allInfoList[toPosition].time = time - EhDB.updateDownloadInfo(allInfoList.slice(fromPosition..toPosition)) - } + fun moveDownload(fromPosition: Int, toPosition: Int): List { + val info = allInfoList.removeAt(fromPosition) + allInfoList.add(toPosition, info) + val range = if (fromPosition < toPosition) fromPosition..toPosition else toPosition..fromPosition + val list = allInfoList.slice(range) + val limit = allInfoList.size - 1 + list.zip(range).forEach { it.first.position = limit - it.second } val label = allInfoList[fromPosition].label - allInfoList.sortByDateDescending() - val list = getInfoListForLabel(label)!! - list.sortByDateDescending() + getInfoListForLabel(label)!!.sortByPositionDescending() + return list } - suspend fun moveDownload(label: String?, fromPosition: Int, toPosition: Int) { - val list = getInfoListForLabel(label) - list?.let { + fun moveDownload(label: String?, fromPosition: Int, toPosition: Int) = + getInfoListForLabel(label)!!.let { val absFromPosition = allInfoList.indexOf(it[fromPosition]) val absToPosition = allInfoList.indexOf(it[toPosition]) moveDownload(absFromPosition, absToPosition) } - } suspend fun resetAllReadingProgress() = coroutineScope { mAllInfoMap.keys.forEach { gid -> @@ -699,7 +663,7 @@ object DownloadManager : OnSpiderListener { srcList.remove(info) dstList.add(info) info.label = label - dstList.sortByDateDescending() + dstList.sortByPositionDescending() // Save to DB EhDB.putDownloadInfo(info) @@ -780,7 +744,7 @@ object DownloadManager : OnSpiderListener { } // Sort - defaultInfoList.sortByDateDescending() + defaultInfoList.sortByPositionDescending() } val isIdle: Boolean @@ -1199,8 +1163,8 @@ object DownloadManager : OnSpiderListener { private const val TYPE_ON_PAGE_FAILURE = 4 private const val TYPE_ON_FINISH = 5 - private fun MutableList.sortByDateDescending() { - sortByDescending { it.time } + private fun MutableList.sortByPositionDescending() { + sortByDescending { it.position } } } diff --git a/app/src/main/java/com/hippo/ehviewer/ui/scene/DownloadsScene.kt b/app/src/main/java/com/hippo/ehviewer/ui/scene/DownloadsScene.kt index d84e5b87d..e3964e53e 100644 --- a/app/src/main/java/com/hippo/ehviewer/ui/scene/DownloadsScene.kt +++ b/app/src/main/java/com/hippo/ehviewer/ui/scene/DownloadsScene.kt @@ -322,15 +322,14 @@ class DownloadsScene : if (fromPosition == toPosition) { return false } + val list = when (mLabel) { + null -> DownloadManager.moveDownload(fromPosition, toPosition) + getString(R.string.default_download_label_name) -> DownloadManager.moveDownload(null, fromPosition, toPosition) + else -> DownloadManager.moveDownload(mLabel, fromPosition, toPosition) + } + mAdapter!!.notifyItemMoved(fromPosition, toPosition) lifecycleScope.launchIO { - when (mLabel) { - null -> DownloadManager.moveDownload(fromPosition, toPosition) - getString(R.string.default_download_label_name) -> DownloadManager.moveDownload(null, fromPosition, toPosition) - else -> DownloadManager.moveDownload(mLabel, fromPosition, toPosition) - } - withUIContext { - mAdapter!!.notifyItemMoved(fromPosition, toPosition) - } + EhDB.updateDownloadInfo(list) } return true }