feat(settings): badge dictionaries that have updates
Report which sources have a newer file instead of a single flag, and show a Material badge at the top-end corner of each such dictionary's icon. The section icon carries the badge while the section is collapsed.
This commit is contained in:
1 parent
c9d1a3439c
commit
911e8b61f6
10 files changed
+120
-76
No files matched your search
@@ -7,7 +7,7 @@ sealed interface DictionaryStatus {
|
|||||||
data object Unknown : DictionaryStatus
|
data object Unknown : DictionaryStatus
|
||||||
data object Checking : DictionaryStatus
|
data object Checking : DictionaryStatus
|
||||||
data object UpToDate : DictionaryStatus
|
data object UpToDate : DictionaryStatus
|
||||||
data object NeedsUpdate : DictionaryStatus
|
data class NeedsUpdate(val sourceIds: Set<String>) : DictionaryStatus
|
||||||
data object Empty : DictionaryStatus
|
data object Empty : DictionaryStatus
|
||||||
data class Error(val message: String) : DictionaryStatus
|
data class Error(val message: String) : DictionaryStatus
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.research.common.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
private val BadgeOutset = 4.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BadgedIcon(
|
||||||
|
painter: Painter,
|
||||||
|
tint: Color,
|
||||||
|
size: Dp,
|
||||||
|
badgeDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.size(size)) {
|
||||||
|
Icon(
|
||||||
|
painter = painter,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
)
|
||||||
|
if (badgeDescription != null) {
|
||||||
|
Badge(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = BadgeOutset, y = -BadgeOutset)
|
||||||
|
.testTag("badge")
|
||||||
|
.semantics { contentDescription = badgeDescription }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -29,7 +28,8 @@ fun CollapsibleHeader(
|
|||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
testTag: String? = null
|
testTag: String? = null,
|
||||||
|
badgeDescription: String? = null,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -43,11 +43,11 @@ fun CollapsibleHeader(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
BadgedIcon(
|
||||||
painter = painterResource(iconRes),
|
painter = painterResource(iconRes),
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(24.dp)
|
size = 24.dp,
|
||||||
|
badgeDescription = badgeDescription,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
+18
-45
@@ -50,63 +50,36 @@ class DictionaryChecker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun areDictionariesUpToDate(
|
suspend fun sourcesWithUpdates(
|
||||||
folderUri: Uri,
|
folderUri: Uri,
|
||||||
sources: List<DictionarySource>,
|
sources: List<DictionarySource>,
|
||||||
files: List<DocumentFileInfo>? = null
|
files: List<DocumentFileInfo>? = null
|
||||||
): Boolean = withContext(Dispatchers.IO) {
|
): Set<String> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val enabledSources = sources.filter { it.isEnabled }
|
|
||||||
if (enabledSources.isEmpty()) {
|
|
||||||
return@withContext true
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentDate = DateUtils.getCurrentDateString()
|
val currentDate = DateUtils.getCurrentDateString()
|
||||||
val previousDate = DateUtils.getPreviousDateString()
|
val previousDate = DateUtils.getPreviousDateString()
|
||||||
val dictFiles = getDictFiles(folderUri, files)
|
val dictFiles = getDictFiles(folderUri, files)
|
||||||
|
sources
|
||||||
val datedSources = enabledSources.filter { source ->
|
.filter { it.isEnabled && DictionarySource.hasDatePlaceholder(it.urlTemplate) }
|
||||||
DictionarySource.hasDatePlaceholder(source.urlTemplate)
|
.filter { source ->
|
||||||
}
|
when {
|
||||||
if (datedSources.isEmpty()) {
|
dictFiles.hasFileForSource(source, currentDate) -> false
|
||||||
return@withContext true
|
dictFiles.hasFileForSource(source, previousDate) ->
|
||||||
}
|
isSourceAvailableOnServer(source, currentDate)
|
||||||
|
else -> when (probeSource(source, currentDate)) {
|
||||||
val allHaveToday = datedSources.all { source ->
|
SourceAvailability.AVAILABLE -> true
|
||||||
dictFiles.hasFileForSource(source, currentDate)
|
SourceAvailability.NOT_FOUND ->
|
||||||
}
|
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
|
||||||
|
SourceAvailability.UNKNOWN -> false
|
||||||
if (allHaveToday) {
|
}
|
||||||
return@withContext true
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val allHaveYesterday = datedSources.all { source ->
|
|
||||||
dictFiles.hasFileForSource(source, previousDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allHaveYesterday) {
|
|
||||||
val serverHasToday = datedSources.any { source ->
|
|
||||||
isSourceAvailableOnServer(source, currentDate)
|
|
||||||
}
|
}
|
||||||
|
.mapTo(mutableSetOf(), DictionarySource::id)
|
||||||
return@withContext !serverHasToday
|
|
||||||
}
|
|
||||||
|
|
||||||
val serverCanOfferNewer = datedSources.any { source ->
|
|
||||||
when (probeSource(source, currentDate)) {
|
|
||||||
SourceAvailability.AVAILABLE -> true
|
|
||||||
SourceAvailability.NOT_FOUND ->
|
|
||||||
probeSource(source, previousDate) == SourceAvailability.AVAILABLE
|
|
||||||
SourceAvailability.UNKNOWN -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return@withContext !serverCanOfferNewer
|
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
Log.e(TAG, "Error checking currency: ${e.message}", e)
|
||||||
true
|
emptySet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-8
@@ -34,14 +34,11 @@ class DictionaryRepository(
|
|||||||
private const val TAG = "DictionaryRepository"
|
private const val TAG = "DictionaryRepository"
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun areDictionariesUpToDate(
|
suspend fun sourcesWithUpdates(
|
||||||
sources: List<DictionarySource>? = null
|
sources: List<DictionarySource>
|
||||||
): Boolean = withContext(Dispatchers.IO) {
|
): Set<String> = withContext(Dispatchers.IO) {
|
||||||
val folderUri = preferencesManager.dictionaryPath
|
val uri = Uri.fromFile(File(preferencesManager.dictionaryPath))
|
||||||
val uri = Uri.fromFile(File(folderUri))
|
checker.sourcesWithUpdates(uri, sources, fileStorageManager.listFilesInFolder(uri))
|
||||||
val files = fileStorageManager.listFilesInFolder(uri)
|
|
||||||
val sourcesToCheck = sources ?: preferencesManager.dictionarySources.first()
|
|
||||||
checker.areDictionariesUpToDate(uri, sourcesToCheck, files)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
|
suspend fun getEnabledSources(sourceUrls: List<String>? = null): List<DictionarySource> =
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class SettingsViewModel(
|
|||||||
dictionaryStatus.collect { status ->
|
dictionaryStatus.collect { status ->
|
||||||
val persistedName = when (status) {
|
val persistedName = when (status) {
|
||||||
DictionaryStatus.UpToDate -> "UpToDate"
|
DictionaryStatus.UpToDate -> "UpToDate"
|
||||||
DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
|
is DictionaryStatus.NeedsUpdate -> "NeedsUpdate"
|
||||||
DictionaryStatus.Empty -> "Empty"
|
DictionaryStatus.Empty -> "Empty"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
@@ -365,8 +365,8 @@ class SettingsViewModel(
|
|||||||
return DictionaryStatus.UpToDate
|
return DictionaryStatus.UpToDate
|
||||||
}
|
}
|
||||||
|
|
||||||
val upToDate = dictionaryRepository.areDictionariesUpToDate(enabledSources)
|
val updatable = dictionaryRepository.sourcesWithUpdates(enabledSources)
|
||||||
return if (upToDate) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate
|
return if (updatable.isEmpty()) DictionaryStatus.UpToDate else DictionaryStatus.NeedsUpdate(updatable)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
|
android.util.Log.e("SettingsViewModel", "evaluateDictionaryStatus: Error", e)
|
||||||
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
|
if (localDictionaryRepository.dictionaries.value.isNotEmpty()) {
|
||||||
@@ -625,11 +625,7 @@ class SettingsViewModel(
|
|||||||
val savedStatus = preferencesManager.dictionaryStatus()
|
val savedStatus = preferencesManager.dictionaryStatus()
|
||||||
val savedStatusName = savedStatus?.substringBefore('|')
|
val savedStatusName = savedStatus?.substringBefore('|')
|
||||||
val savedStatusDate = savedStatus?.substringAfter('|', "")
|
val savedStatusDate = savedStatus?.substringAfter('|', "")
|
||||||
val restoredStatus = when (savedStatusName) {
|
val restoredStatus = if (savedStatusName == "UpToDate") DictionaryStatus.UpToDate else null
|
||||||
"UpToDate" -> DictionaryStatus.UpToDate
|
|
||||||
"NeedsUpdate" -> DictionaryStatus.NeedsUpdate
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (restoredStatus != null &&
|
if (restoredStatus != null &&
|
||||||
savedStatusDate == DateUtils.getCurrentDateString() &&
|
savedStatusDate == DateUtils.getCurrentDateString() &&
|
||||||
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
|
withContext(Dispatchers.IO) { installedDictionariesAreCurrent() }
|
||||||
|
|||||||
+10
-4
@@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.example.research.R
|
import com.example.research.R
|
||||||
|
import com.example.research.common.ui.components.BadgedIcon
|
||||||
import com.example.research.core.domain.model.Dictionary
|
import com.example.research.core.domain.model.Dictionary
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -84,7 +85,8 @@ fun DictionaryListItem(
|
|||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isDeleteBlocked: () -> Boolean = { false }
|
isDeleteBlocked: () -> Boolean = { false },
|
||||||
|
hasUpdate: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -195,11 +197,15 @@ fun DictionaryListItem(
|
|||||||
modifier = Modifier.size(IconButtonSize),
|
modifier = Modifier.size(IconButtonSize),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
BadgedIcon(
|
||||||
painter = painterResource(R.drawable.ic_book),
|
painter = painterResource(R.drawable.ic_book),
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(IconSize)
|
size = IconSize,
|
||||||
|
badgeDescription = if (hasUpdate) {
|
||||||
|
stringResource(R.string.dictionary_update_available)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-5
@@ -48,6 +48,7 @@ import com.example.research.common.ui.components.CollapsibleHeader
|
|||||||
import com.example.research.common.ui.components.EmptyState
|
import com.example.research.common.ui.components.EmptyState
|
||||||
import com.example.research.common.ui.components.SectionCard
|
import com.example.research.common.ui.components.SectionCard
|
||||||
import com.example.research.core.domain.model.Dictionary
|
import com.example.research.core.domain.model.Dictionary
|
||||||
|
import com.example.research.core.domain.usecase.DictionarySourceFileMatcher
|
||||||
import com.example.research.core.domain.model.DictionarySource
|
import com.example.research.core.domain.model.DictionarySource
|
||||||
import com.example.research.feature.download.model.DownloadState
|
import com.example.research.feature.download.model.DownloadState
|
||||||
import com.example.research.ui.settings.ImportState
|
import com.example.research.ui.settings.ImportState
|
||||||
@@ -94,6 +95,21 @@ fun DictionaryManagement(
|
|||||||
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
|
derivedStateOf { importState is ImportState.Importing || importState is ImportState.Extracting }
|
||||||
}
|
}
|
||||||
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
|
val hasEnabledSources by remember(sources) { derivedStateOf { sources.any { it.isEnabled } } }
|
||||||
|
val isUpdateAvailable = dictionaryStatus is DictionaryStatus.NeedsUpdate &&
|
||||||
|
!isInProgress && hasEnabledSources && dictionaries.isNotEmpty()
|
||||||
|
val updatablePaths = remember(dictionaryStatus, sources, dictionaries, isUpdateAvailable) {
|
||||||
|
val ids = (dictionaryStatus as? DictionaryStatus.NeedsUpdate)?.sourceIds
|
||||||
|
if (!isUpdateAvailable || ids.isNullOrEmpty()) {
|
||||||
|
emptySet()
|
||||||
|
} else {
|
||||||
|
val updatableSources = sources.filter { it.id in ids }
|
||||||
|
dictionaries
|
||||||
|
.filter { dictionary ->
|
||||||
|
updatableSources.any { DictionarySourceFileMatcher.matches(it, dictionary) }
|
||||||
|
}
|
||||||
|
.mapTo(mutableSetOf(), Dictionary::path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
|
// Invariant: the mutation-block flag is read only at state-read/gesture time (derivedStateOf,
|
||||||
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
|
// snapshotFlow, pointerInput callbacks), never at row composition time. A composition-time read
|
||||||
@@ -122,7 +138,12 @@ fun DictionaryManagement(
|
|||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
onToggle = onToggleExpanded,
|
onToggle = onToggleExpanded,
|
||||||
subtitle = headerSubtitle,
|
subtitle = headerSubtitle,
|
||||||
testTag = "dictionary_header"
|
testTag = "dictionary_header",
|
||||||
|
badgeDescription = if (isUpdateAvailable && !isExpanded) {
|
||||||
|
stringResource(R.string.dictionaries_update_available)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -182,7 +203,7 @@ fun DictionaryManagement(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (dictionaryStatus is DictionaryStatus.NeedsUpdate && !isInProgress && hasEnabledSources) {
|
if (isUpdateAvailable) {
|
||||||
DictionaryUpdateCard(
|
DictionaryUpdateCard(
|
||||||
onStartDownload = onStartDownload,
|
onStartDownload = onStartDownload,
|
||||||
modifier = backgroundModifier
|
modifier = backgroundModifier
|
||||||
@@ -196,7 +217,8 @@ fun DictionaryManagement(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.then(backgroundModifier),
|
.then(backgroundModifier),
|
||||||
isDeleteBlocked = isDeleteBlocked
|
isDeleteBlocked = isDeleteBlocked,
|
||||||
|
updatablePaths = updatablePaths,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isInProgress) {
|
if (!isInProgress) {
|
||||||
@@ -279,7 +301,8 @@ private fun DictionaryListSection(
|
|||||||
onToggleDictionary: (String) -> Unit,
|
onToggleDictionary: (String) -> Unit,
|
||||||
onDeleteDictionary: (Dictionary) -> Unit,
|
onDeleteDictionary: (Dictionary) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isDeleteBlocked: () -> Boolean = { false }
|
isDeleteBlocked: () -> Boolean = { false },
|
||||||
|
updatablePaths: Set<String> = emptySet(),
|
||||||
) {
|
) {
|
||||||
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
|
var appearedPaths by remember { mutableStateOf(emptySet<String>()) }
|
||||||
LaunchedEffect(dictionaries) {
|
LaunchedEffect(dictionaries) {
|
||||||
@@ -306,7 +329,8 @@ private fun DictionaryListSection(
|
|||||||
dictionary = dictionary,
|
dictionary = dictionary,
|
||||||
onToggle = { onToggleDictionary(dictionary.path) },
|
onToggle = { onToggleDictionary(dictionary.path) },
|
||||||
onDelete = { onDeleteDictionary(dictionary) },
|
onDelete = { onDeleteDictionary(dictionary) },
|
||||||
isDeleteBlocked = isDeleteBlocked
|
isDeleteBlocked = isDeleteBlocked,
|
||||||
|
hasUpdate = dictionary.path in updatablePaths,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
|
<string name="dictionary_source_suspicious_characters">URL содержит недопустимые символы</string>
|
||||||
<string name="dictionaries_not_downloaded">Словари еще не загружены</string>
|
<string name="dictionaries_not_downloaded">Словари еще не загружены</string>
|
||||||
<string name="dictionaries_update_available">Доступны обновления словарей</string>
|
<string name="dictionaries_update_available">Доступны обновления словарей</string>
|
||||||
|
<string name="dictionary_update_available">Доступно обновление</string>
|
||||||
<string name="action_cancel">Отмена</string>
|
<string name="action_cancel">Отмена</string>
|
||||||
<string name="action_add">Добавить</string>
|
<string name="action_add">Добавить</string>
|
||||||
<string name="action_add_another_url">Добавить ещё URL</string>
|
<string name="action_add_another_url">Добавить ещё URL</string>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
|
<string name="dictionary_source_suspicious_characters">URL contains invalid characters</string>
|
||||||
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
|
<string name="dictionaries_not_downloaded">No dictionaries downloaded yet</string>
|
||||||
<string name="dictionaries_update_available">Dictionary updates available</string>
|
<string name="dictionaries_update_available">Dictionary updates available</string>
|
||||||
|
<string name="dictionary_update_available">Update available</string>
|
||||||
<string name="action_cancel">Cancel</string>
|
<string name="action_cancel">Cancel</string>
|
||||||
<string name="action_add">Add</string>
|
<string name="action_add">Add</string>
|
||||||
<string name="action_add_another_url">Add another URL</string>
|
<string name="action_add_another_url">Add another URL</string>
|
||||||
|
|||||||
Reference in new issue
Block a user