11 Commits
Author SHA1 Message Date
OneWay 03b706c9f7 refactor(prefs): create the settings store through DataStore.Builder
The store is now built through DataStore.Builder instead of the
preferencesDataStore delegate.
2026-10-22 21:48:01 +08:00
OneWay 4bdd185434 fix(about): survive a licenses parse failure 2026-10-22 21:43:15 +08:00
OneWay 79a7ce3059 chore(deps): bump Gradle wrapper to 9.8.2 2026-10-21 04:34:31 +08:00
OneWay 91627d181e chore(deps): bump kotlinx.serialization to 1.12.0 2026-10-21 04:33:24 +08:00
OneWay 692fb2117e fix(download): unblock search after a failed update
A failed update left DownloadState.Error in the state flow forever:
settings replayed it as a snackbar on every recomposition and search
stayed locked until the next app start.
2026-10-21 04:00:00 +08:00
OneWay 3bb4e09b04 chore(deps): bump aboutlibraries to 15.3.0 2026-10-15 13:52:00 +08:00
OneWay ff4ba62bbd chore(deps): bump paging to 3.5.3 2026-10-15 13:41:07 +08:00
OneWay 123615e1f8 fix(download): expire the sticky error state
A download error stayed in the state flow forever: the dictionaries
section replayed it as a snackbar on every recomposition and screens
created later picked it up long after the failure, even while a newer
download was already running. Error states now decay to idle after the
same grace period as success, unless a newer state has replaced them.
2026-10-15 13:38:00 +08:00
OneWay 6889dd2c45 fix(settings): keep the update badge after a failed download
Errors of downloads this screen did not start no longer touch the
status; when the prior status is unknown or still unresolved, today's
persisted status is restored or a fresh evaluation runs instead of
fabricating UpToDate.
2026-10-15 13:34:09 +08:00
OneWay f22028d126 Search results: one ordering pass instead of double ranking
The search pipeline ranked and deduplicated matches twice: inside the
searcher per dictionary and again after merging across dictionaries.
The extra pass ran before the required ordering and duplicated the
dedup work. The searcher now returns article-deduplicated matches and
the paging source applies a single ordering pass: display length
ascending, then natural numeric-aware order. Paging configuration
knobs that a full-page source cannot use are dropped.
2026-10-15 13:06:05 +08:00
OneWay e20af9c1ff fix(settings): keep the update badge across PROCESS_TEXT relaunches
Persist NeedsUpdate with its source ids and restore today's persisted
status — UpToDate and NeedsUpdate alike — through one shared helper used
by both startup paths, falling back to a fresh evaluation when the
persisted status can no longer be trusted (stale date, removed sources,
or the update already installed).
2026-10-14 23:33:51 +08:00
13 changed files with 181 additions and 94 deletions

No files matched your search

+4 -4
View File
@@ -89,11 +89,11 @@ The main search screen remains usable as long as at least one indexed dictionary
- Jetpack Compose (BOM 2026.09.00) - Jetpack Compose (BOM 2026.09.00)
- Material 3 1.5.0-beta01 - Material 3 1.5.0-beta01
- Coroutines and Flow 1.11.0 - Coroutines and Flow 1.11.0
- DataStore Preferences 1.2.1 - DataStore Preferences 1.3.0
- Paging 3.5.2 - Paging 3.5.3
- OkHttp 5.5.0 - OkHttp 5.5.0
- kotlinx.serialization 1.11.0 - kotlinx.serialization 1.12.0
- AboutLibraries 15.2.0 metadata generation - AboutLibraries 15.3.0 metadata generation
- Android Gradle Plugin 9.4.1 - Android Gradle Plugin 9.4.1
## Build ## Build
@@ -2,14 +2,20 @@ package com.example.research.data.local.preferences
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.core.FileStorage
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferencesFileSerializer
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStoreFile
import com.example.research.core.domain.model.DictionarySource import com.example.research.core.domain.model.DictionarySource
import com.example.research.core.domain.model.SearchBarPosition import com.example.research.core.domain.model.SearchBarPosition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -17,7 +23,23 @@ import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") private const val SETTINGS_STORE_NAME = "settings"
private val storeLock = Any()
@Volatile
private var settingsStore: DataStore<Preferences>? = null
private val Context.dataStore: DataStore<Preferences>
get() = settingsStore ?: synchronized(storeLock) {
settingsStore ?: DataStore.Builder(
storage = FileStorage(PreferencesFileSerializer) {
applicationContext.preferencesDataStoreFile(SETTINGS_STORE_NAME)
},
context = Dispatchers.IO + SupervisorJob(),
).setCorruptionHandler(ReplaceFileCorruptionHandler { emptyPreferences() }).build()
.also { settingsStore = it }
}
data class SectionExpansionStates( data class SectionExpansionStates(
val isThemeExpanded: Boolean = false, val isThemeExpanded: Boolean = false,
@@ -5,10 +5,9 @@ import androidx.paging.PagingState
import com.example.research.core.domain.model.Dictionary import com.example.research.core.domain.model.Dictionary
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.performance.ReSearchTrace import com.example.research.core.performance.ReSearchTrace
import com.example.research.core.util.sanitizeQuery
import com.example.research.data.parser.IndexSearcher import com.example.research.data.parser.IndexSearcher
import com.example.research.data.parser.SearchRankingContext import com.example.research.data.parser.sortByCharCountThenNatural
import com.example.research.data.parser.rankBySearchRelevance
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -68,9 +67,8 @@ class IndexEntryPagingSource(
} }
} }
val normalizedQuery = query.sanitizeQuery().lowercase().trim()
val ranked = rawResults val ranked = rawResults
.rankBySearchRelevance(SearchRankingContext(normalizedQuery)) .sortByCharCountThenNatural()
.distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) } .distinctBy { Triple(it.dictionaryPath, it.word, it.offset.value) }
LoadResult.Page( LoadResult.Page(
@@ -122,9 +122,7 @@ class IndexSearcher(private val context: Context) {
} }
} }
} }
(prefixMatches + substringMatches) (prefixMatches + substringMatches).distinctBy { it.articleKey() }
.rankBySearchRelevance(SearchRankingContext(normalizedQuery, foldedQuery))
.distinctBy { it.articleKey() }
} }
resultsCache.put(cacheKey, results) resultsCache.put(cacheKey, results)
results results
@@ -2,50 +2,37 @@ package com.example.research.data.parser
import com.example.research.core.domain.model.IndexEntry import com.example.research.core.domain.model.IndexEntry
import com.example.research.core.util.NaturalStringComparator import com.example.research.core.util.NaturalStringComparator
import com.example.research.core.util.foldLatinDiacritics
internal data class SearchRankingContext( internal fun List<IndexEntry>.sortByCharCountThenNatural(): List<IndexEntry> {
val normalizedQuery: String,
val foldedQuery: String = normalizedQuery.foldLatinDiacritics(),
)
private data class SortKey(
val matchTier: Int,
val exactMatch: Int,
val charCount: Int,
val word: String,
val entry: IndexEntry,
)
internal fun List<IndexEntry>.rankBySearchRelevance(
ranking: SearchRankingContext
): List<IndexEntry> {
if (size < 2) return this if (size < 2) return this
return map { entry -> val charCounts = IntArray(size)
val word = entry.word var minCount = Int.MAX_VALUE
var maxCount = Int.MIN_VALUE
for (i in indices) {
val entry = this[i]
val displayKey = entry.originalWord val displayKey = entry.originalWord
.trim() .trim()
.ifEmpty { entry.word.trim() } .ifEmpty { entry.word.trim() }
val count = displayKey.codePointCount(0, displayKey.length)
SortKey( charCounts[i] = count
matchTier = when { if (count < minCount) minCount = count
!entry.isAlias && word.startsWith(ranking.normalizedQuery) -> 0 if (count > maxCount) maxCount = count
entry.isAlias && word.startsWith(ranking.foldedQuery) -> 1
!entry.isAlias && word.foldLatinDiacritics().startsWith(ranking.foldedQuery) -> 1
else -> 2
},
exactMatch = if (word == ranking.normalizedQuery) 0 else 1,
charCount = displayKey.codePointCount(0, displayKey.length),
word = word,
entry = entry,
)
} }
.sortedWith(
compareBy<SortKey> { it.matchTier } val buckets = arrayOfNulls<ArrayList<IndexEntry>>(maxCount - minCount + 1)
.thenBy { it.exactMatch } for (i in indices) {
.thenBy { it.charCount } val bucketIndex = charCounts[i] - minCount
.thenBy(NaturalStringComparator) { it.word } (buckets[bucketIndex] ?: ArrayList<IndexEntry>().also { buckets[bucketIndex] = it })
) .add(this[i])
.map { it.entry } }
val naturalOrder = compareBy<IndexEntry, String>(NaturalStringComparator) { it.word }
val result = ArrayList<IndexEntry>(size)
for (bucket in buckets) {
if (bucket == null) continue
if (bucket.size > 1) bucket.sortWith(naturalOrder)
result.addAll(bucket)
}
return result
} }
@@ -52,6 +52,13 @@ class DownloadManager(
var onDownloadSuccess: (() -> Unit)? = null var onDownloadSuccess: (() -> Unit)? = null
var onTerminal: ((DownloadState) -> Unit)? = null var onTerminal: ((DownloadState) -> Unit)? = null
private suspend fun expireErrorStateToIdle() {
delay(TERMINAL_STATE_DURATION)
if (downloadState.value is DownloadState.Error) {
updateDownloadProgressState(DownloadState.Idle, 0f)
}
}
fun startDownload() { fun startDownload() {
if (downloadState.value is DownloadState.Loading) return if (downloadState.value is DownloadState.Loading) return
if (!downloadScope.isActive) return if (!downloadScope.isActive) return
@@ -79,6 +86,7 @@ class DownloadManager(
updateDownloadProgressState( updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f DownloadState.Error(sourceUnavailableMessage), 0f
) )
expireErrorStateToIdle()
return@withLock return@withLock
} }
@@ -135,6 +143,7 @@ class DownloadManager(
} }
} }
expireErrorStateToIdle()
} }
} finally { } finally {
downloadJob = null downloadJob = null
@@ -174,6 +183,7 @@ class DownloadManager(
updateDownloadProgressState( updateDownloadProgressState(
DownloadState.Error(sourceUnavailableMessage), 0f DownloadState.Error(sourceUnavailableMessage), 0f
) )
expireErrorStateToIdle()
return@withLock return@withLock
} }
@@ -230,6 +240,7 @@ class DownloadManager(
} }
} }
expireErrorStateToIdle()
} }
} finally { } finally {
downloadJob = null downloadJob = null
@@ -112,9 +112,7 @@ class SearchViewModel(
Pager( Pager(
config = PagingConfig( config = PagingConfig(
pageSize = 12, pageSize = 12,
prefetchDistance = 1, enablePlaceholders = false
enablePlaceholders = false,
initialLoadSize = 12
), ),
pagingSourceFactory = { pagingSourceFactory = {
IndexEntryPagingSource( IndexEntryPagingSource(
@@ -69,7 +69,7 @@ fun AboutScreen(
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val librariesData by produceState<AboutLibrariesData?>(initialValue = null, context) { val librariesData by produceState<AboutLibrariesData?>(initialValue = null, context) {
value = withContext(Dispatchers.IO) { value = withContext(Dispatchers.IO) {
AboutLibrariesParser.read(context, R.raw.aboutlibraries) runCatching { AboutLibrariesParser.read(context, R.raw.aboutlibraries) }.getOrNull()
} }
} }
var expandedLibraryId by remember { mutableStateOf<String?>(null) } var expandedLibraryId by remember { mutableStateOf<String?>(null) }
@@ -64,7 +64,11 @@ fun MainRoute(
dictionary.isActive && dictionary.articleCount > 0 dictionary.isActive && dictionary.articleCount > 0
} }
settingsState.downloadState is DownloadState.Idle && val downloadSettled = settingsState.downloadState.let { state ->
state is DownloadState.Idle || state is DownloadState.Error
}
downloadSettled &&
!settingsState.indexingProgress.isIndexing && !settingsState.indexingProgress.isIndexing &&
settingsState.importState !is ImportState.Importing && settingsState.importState !is ImportState.Importing &&
settingsState.importState !is ImportState.Extracting && settingsState.importState !is ImportState.Extracting &&
@@ -37,6 +37,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
private const val STATUS_UP_TO_DATE = "UpToDate"
private const val STATUS_NEEDS_UPDATE = "NeedsUpdate"
private const val STATUS_EMPTY = "Empty"
private data class PersistedDictionaryStatus(
val name: String,
val date: String,
val sourceIds: Set<String>,
)
private data class DictionaryStateInputs( private data class DictionaryStateInputs(
val theme: AppTheme, val theme: AppTheme,
val dictionarySources: List<DictionarySource>, val dictionarySources: List<DictionarySource>,
@@ -115,14 +125,18 @@ class SettingsViewModel(
private fun persistDictionaryStatus() { private fun persistDictionaryStatus() {
viewModelScope.launch { viewModelScope.launch {
dictionaryStatus.collect { status -> dictionaryStatus.collect { status ->
val persistedName = when (status) { val persisted = when (status) {
DictionaryStatus.UpToDate -> "UpToDate" DictionaryStatus.UpToDate ->
is DictionaryStatus.NeedsUpdate -> "NeedsUpdate" "$STATUS_UP_TO_DATE|${DateUtils.getCurrentDateString()}"
DictionaryStatus.Empty -> "Empty" is DictionaryStatus.NeedsUpdate ->
"$STATUS_NEEDS_UPDATE|${DateUtils.getCurrentDateString()}|" +
status.sourceIds.joinToString(",")
DictionaryStatus.Empty ->
"$STATUS_EMPTY|${DateUtils.getCurrentDateString()}"
else -> null else -> null
} }
if (persistedName != null) { if (persisted != null) {
preferencesManager.saveDictionaryStatus("$persistedName|${DateUtils.getCurrentDateString()}") preferencesManager.saveDictionaryStatus(persisted)
} }
} }
} }
@@ -202,14 +216,21 @@ class SettingsViewModel(
} }
} }
is DownloadState.Error -> { is DownloadState.Error -> {
val startedHere = statusBeforeDownload != DictionaryStatus.Unknown ||
isDownloadInProgress
isDownloadInProgress = false isDownloadInProgress = false
if (!errorStateHandled) { if (!errorStateHandled) {
errorStateHandled = true errorStateHandled = true
dictionaryStatus.value = if (dictionaryState.dictionaries.isEmpty()) { if (dictionaryState.dictionaries.isEmpty()) {
DictionaryStatus.Empty dictionaryStatus.value = DictionaryStatus.Empty
} else when (statusBeforeDownload) { } else if (startedHere) {
DictionaryStatus.Unknown, DictionaryStatus.Checking -> DictionaryStatus.UpToDate when (statusBeforeDownload) {
else -> statusBeforeDownload DictionaryStatus.Unknown, DictionaryStatus.Checking ->
viewModelScope.launch {
restoreOrEvaluateDictionaryStatus()
}
else -> dictionaryStatus.value = statusBeforeDownload
}
} }
if (pendingSourceUrls.isNotEmpty()) { if (pendingSourceUrls.isNotEmpty()) {
pendingSourceUrls.forEach { urlTemplate -> pendingSourceUrls.forEach { urlTemplate ->
@@ -283,7 +304,7 @@ class SettingsViewModel(
scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) { scanCompleted = if (localDictionaryRepository.dictionaries.value.isEmpty()) {
scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success scanDirectoryInternal(preferencesManager.dictionaryPath) is OperationResult.Success
} else { } else {
dictionaryStatus.value = DictionaryStatus.UpToDate restoreOrEvaluateDictionaryStatus()
false false
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -551,6 +572,71 @@ class SettingsViewModel(
): List<DictionarySource> = ): List<DictionarySource> =
DictionarySourceFileMatcher.installedSources(sources, dictionaries) DictionarySourceFileMatcher.installedSources(sources, dictionaries)
private suspend fun restoreOrEvaluateDictionaryStatus() {
val restored = restorePersistedDictionaryStatus()
if (restored != null) {
dictionaryStatus.value = restored
return
}
dictionaryStatus.value = DictionaryStatus.Checking
dictionaryStatus.value = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
}
private suspend fun restorePersistedDictionaryStatus(): DictionaryStatus? {
val persisted = readPersistedDictionaryStatus() ?: return null
if (persisted.date != DateUtils.getCurrentDateString()) return null
return when (persisted.name) {
STATUS_UP_TO_DATE ->
if (withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }) {
DictionaryStatus.UpToDate
} else {
null
}
STATUS_NEEDS_UPDATE -> {
val updatableIds = withContext(Dispatchers.IO) { sourcesMissingTodaysFile() }
val restorableIds = persisted.sourceIds intersect updatableIds
if (restorableIds.isNotEmpty()) DictionaryStatus.NeedsUpdate(restorableIds) else null
}
else -> null
}
}
private suspend fun readPersistedDictionaryStatus(): PersistedDictionaryStatus? {
val raw = preferencesManager.dictionaryStatus() ?: return null
val parts = raw.split('|')
if (parts.isEmpty() || parts.first().isEmpty()) return null
return PersistedDictionaryStatus(
name = parts.first(),
date = parts.getOrElse(1) { "" },
sourceIds = parts.getOrElse(2) { "" }
.split(',')
.map(String::trim)
.filter(String::isNotEmpty)
.toSet(),
)
}
private suspend fun sourcesMissingTodaysFile(): Set<String> {
val sources = preferencesManager.dictionarySources.first()
val dictionaries = localDictionaryRepository.dictionaries.first()
val datedEnabledSources = installedEnabledSources(sources, dictionaries)
.filter { DictionarySource.hasDatePlaceholder(it.urlTemplate) }
if (datedEnabledSources.isEmpty()) return emptySet()
val fileNames = localDictionaryRepository.listDictionaryPayloadFileNames(
preferencesManager.dictionaryPath
) ?: return emptySet()
val today = DateUtils.getCurrentDateString()
return datedEnabledSources
.filterNot { source -> fileNames.any { it.startsWith(fileBaseNameFor(source, today)) } }
.mapTo(mutableSetOf(), DictionarySource::id)
}
private fun fileBaseNameFor(source: DictionarySource, date: String): String =
DictionarySource.extractFileName(source.urlTemplate, date)
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
private suspend fun installedDictionariesAreCurrent(): Boolean { private suspend fun installedDictionariesAreCurrent(): Boolean {
val sources = preferencesManager.dictionarySources.first() val sources = preferencesManager.dictionarySources.first()
val dictionaries = localDictionaryRepository.dictionaries.first() val dictionaries = localDictionaryRepository.dictionaries.first()
@@ -562,11 +648,7 @@ class SettingsViewModel(
) ?: return true ) ?: return true
val today = DateUtils.getCurrentDateString() val today = DateUtils.getCurrentDateString()
return datedEnabledSources.all { source -> return datedEnabledSources.all { source ->
val baseName = DictionarySource.extractFileName(source.urlTemplate, today) fileNames.any { it.startsWith(fileBaseNameFor(source, today)) }
.removeSuffix(".gz")
.removeSuffix(".dsl")
.removeSuffix(".dz")
fileNames.any { it.startsWith(baseName) }
} }
} }
@@ -622,20 +704,7 @@ class SettingsViewModel(
dictionaryStatus.value = DictionaryStatus.UpToDate dictionaryStatus.value = DictionaryStatus.UpToDate
isDownloadInProgress = false isDownloadInProgress = false
} else { } else {
val savedStatus = preferencesManager.dictionaryStatus() restoreOrEvaluateDictionaryStatus()
val savedStatusName = savedStatus?.substringBefore('|')
val savedStatusDate = savedStatus?.substringAfter('|', "")
val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate else null
if (restoredStatus != null &&
savedStatusDate == DateUtils.getCurrentDateString() &&
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
) {
dictionaryStatus.value = restoredStatus
} else {
dictionaryStatus.value = DictionaryStatus.Checking
val status = withContext(Dispatchers.IO) { evaluateDictionaryStatus() }
dictionaryStatus.value = status
}
} }
if (result.data > 0) { if (result.data > 0) {
+4 -4
View File
@@ -1,19 +1,19 @@
[versions] [versions]
aboutlibraries = "15.2.0" aboutlibraries = "15.3.0"
activity_compose = "1.13.0" activity_compose = "1.13.0"
agp = "9.4.1" agp = "9.4.1"
compose_bom = "2026.09.00" compose_bom = "2026.09.00"
core = "1.19.1" core = "1.19.1"
datastore_preferences = "1.2.1" datastore_preferences = "1.3.0"
documentfile = "1.1.0" documentfile = "1.1.0"
kotlin = "2.4.21" kotlin = "2.4.21"
lifecycle_runtime_ktx = "2.11.0" lifecycle_runtime_ktx = "2.11.0"
material3 = "1.5.0-beta01" material3 = "1.5.0-beta01"
okhttp = "5.5.0" okhttp = "5.5.0"
paging = "3.5.2" paging = "3.5.3"
profileinstaller = "1.4.1" profileinstaller = "1.4.1"
kotlinx_coroutines = "1.11.0" kotlinx_coroutines = "1.11.0"
kotlinx_serialization = "1.11.0" kotlinx_serialization = "1.12.0"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" }
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500