Hide codes

This commit is contained in:
Rafał Kobyłko 2023-07-21 15:59:05 +02:00
parent 52a6b3267d
commit a0b06a6fa1
33 changed files with 616 additions and 236 deletions

View File

@ -4,18 +4,29 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -34,6 +45,7 @@ import com.twofasapp.designsystem.service.atoms.ServiceName
import com.twofasapp.designsystem.service.atoms.ServiceTextDefaults
import com.twofasapp.designsystem.service.atoms.ServiceTextStyle
import com.twofasapp.designsystem.service.atoms.ServiceTimer
import com.twofasapp.designsystem.service.atoms.formatCode
internal const val ServiceExpireTransitionThreshold = 5
@ -62,12 +74,14 @@ fun DsService(
style: ServiceStyle = ServiceStyle.Default,
editMode: Boolean = false,
showNextCode: Boolean = false,
hideCodes: Boolean = false,
containerColor: Color = TwTheme.color.background,
dragHandleVisible: Boolean = true,
dragModifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onIncrementCounterClick: (() -> Unit)? = null,
onRevealClick: (() -> Unit)? = null,
) {
val textStyles: ServiceTextStyle = when (style) {
ServiceStyle.Default -> ServiceTextDefaults.default()
@ -129,39 +143,78 @@ fun DsService(
modifier = Modifier.fillMaxWidth(),
)
if (editMode.not()) {
ServiceCode(
code = state.code,
nextCode = state.nextCode,
timer = state.timer,
nextCodeVisible = state.isNextCodeEnabled(showNextCode),
nextCodeGravity = when (style) {
ServiceStyle.Default -> NextCodeGravity.Below
ServiceStyle.Compact -> NextCodeGravity.End
},
animateColor = state.authType == ServiceAuthType.Totp,
textStyles = textStyles,
modifier = Modifier.fillMaxWidth(),
)
if (editMode.not() && (state.revealed || hideCodes.not() || style == ServiceStyle.Default)) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Max)
) {
ServiceCode(
code = state.code,
nextCode = state.nextCode,
timer = state.timer,
nextCodeVisible = state.isNextCodeEnabled(showNextCode),
nextCodeGravity = when (style) {
ServiceStyle.Default -> NextCodeGravity.Below
ServiceStyle.Compact -> NextCodeGravity.End
},
animateColor = state.authType == ServiceAuthType.Totp,
textStyles = textStyles,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.alpha(if (state.revealed || hideCodes.not()) 1f else 0f),
)
if (state.revealed.not() && hideCodes) {
HiddenDots(
formattedCode = state.code.formatCode(),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
)
}
}
}
}
if (editMode.not()) {
when (state.authType) {
ServiceAuthType.Totp -> {
ServiceTimer(
timer = state.timer,
progress = state.progress,
textStyles = textStyles,
dimens = dimens,
modifier = Modifier.padding(end = 12.dp)
)
}
ServiceAuthType.Hotp -> {
ServiceHotp(
enabled = state.hotpCounterEnabled,
onClick = { onIncrementCounterClick?.invoke() }
if (state.revealed || hideCodes.not()) {
when (state.authType) {
ServiceAuthType.Totp -> {
ServiceTimer(
timer = state.timer,
progress = state.progress,
textStyles = textStyles,
dimens = dimens,
modifier = Modifier.padding(end = 20.dp)
)
}
ServiceAuthType.Hotp -> {
ServiceHotp(
enabled = state.hotpCounterEnabled,
onClick = { onIncrementCounterClick?.invoke() },
modifier = Modifier.padding(end = 4.dp)
)
}
}
} else {
Box(
Modifier
.padding(end = 7.dp)
.size(56.dp)
.clip(CircleShape)
.clickable { onRevealClick?.invoke() }
) {
Icon(
painter = TwIcons.Eye,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.align(Alignment.Center),
tint = TwTheme.color.iconTint
)
}
}
@ -171,7 +224,35 @@ fun DsService(
TwIconButton(
painter = TwIcons.DragHandle,
enabled = false,
modifier = dragModifier,
modifier = Modifier
.padding(end = 8.dp)
.then(dragModifier),
)
}
}
}
}
@Composable
internal fun HiddenDots(
modifier: Modifier = Modifier,
formattedCode: String,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
formattedCode.map {
if (it.isWhitespace()) {
Spacer(modifier = Modifier.width(2.dp))
} else {
Box(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(8.dp)
.background(TwTheme.color.onSurfacePrimary, CircleShape)
)
}
}
@ -181,20 +262,29 @@ fun DsService(
@Preview
@Composable
private fun PreviewDefault() {
DsService(state = ServicePreview)
}
@Preview
@Composable
private fun PreviewDefaultHotp() {
DsService(state = ServicePreview.copy(authType = ServiceAuthType.Hotp))
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
DsService(state = ServicePreview)
DsService(state = ServicePreview.copy(timer = 3), showNextCode = true, hideCodes = true)
DsService(state = ServicePreview.copy(timer = 3, revealed = false), showNextCode = true, hideCodes = true)
DsService(state = ServicePreview.copy(authType = ServiceAuthType.Hotp))
}
}
@Preview
@Composable
private fun PreviewCompact() {
DsService(state = ServicePreview, style = ServiceStyle.Compact)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
DsService(state = ServicePreview, style = ServiceStyle.Compact)
DsService(state = ServicePreview.copy(timer = 3), style = ServiceStyle.Compact, showNextCode = true, hideCodes = true)
DsService(state = ServicePreview.copy(revealed = false), style = ServiceStyle.Compact, hideCodes = true)
}
}
@Preview
@ -218,4 +308,5 @@ internal val ServicePreview = ServiceState(
labelText = "2F",
labelColor = Color.Red,
badgeColor = Color.Red,
revealed = true,
)

View File

@ -1,18 +1,28 @@
package com.twofasapp.designsystem.service
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.twofasapp.designsystem.TwIcons
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.service.atoms.ServiceCode
import com.twofasapp.designsystem.service.atoms.ServiceHotp
@ -21,14 +31,17 @@ import com.twofasapp.designsystem.service.atoms.ServiceInfo
import com.twofasapp.designsystem.service.atoms.ServiceName
import com.twofasapp.designsystem.service.atoms.ServiceTextDefaults
import com.twofasapp.designsystem.service.atoms.ServiceTimer
import com.twofasapp.designsystem.service.atoms.formatCode
@Composable
fun DsServiceModal(
state: ServiceState,
showNextCode: Boolean = false,
hideCodes: Boolean = false,
modifier: Modifier = Modifier,
containerColor: Color = TwTheme.color.background,
onIncrementCounterClick: (() -> Unit)? = null,
onRevealClick: (() -> Unit)? = null,
) {
val textStyles = ServiceTextDefaults.modal()
@ -71,30 +84,66 @@ fun DsServiceModal(
labelColor = state.labelColor
)
ServiceCode(
code = state.code,
nextCode = state.nextCode,
timer = state.timer,
nextCodeVisible = state.isNextCodeEnabled(showNextCode),
animateColor = state.authType == ServiceAuthType.Totp,
modifier = Modifier.weight(1f),
textStyles = textStyles,
)
Box(
modifier = Modifier
.weight(1f)
.height(IntrinsicSize.Max)
) {
when (state.authType) {
ServiceAuthType.Totp -> {
ServiceTimer(
timer = state.timer,
progress = state.progress,
textStyles = textStyles,
modifier = Modifier.padding(end = 12.dp)
ServiceCode(
code = state.code,
nextCode = state.nextCode,
timer = state.timer,
nextCodeVisible = state.isNextCodeEnabled(showNextCode),
animateColor = state.authType == ServiceAuthType.Totp,
modifier = Modifier
.fillMaxWidth()
.alpha(if (state.revealed || hideCodes.not()) 1f else 0f),
textStyles = textStyles,
)
if (state.revealed.not() && hideCodes) {
HiddenDots(
formattedCode = state.code.formatCode(),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
)
}
}
ServiceAuthType.Hotp -> {
ServiceHotp(
enabled = state.hotpCounterEnabled,
onClick = { onIncrementCounterClick?.invoke() }
if (state.revealed || hideCodes.not()) {
when (state.authType) {
ServiceAuthType.Totp -> {
ServiceTimer(
timer = state.timer,
progress = state.progress,
textStyles = textStyles,
modifier = Modifier.padding(end = 12.dp)
)
}
ServiceAuthType.Hotp -> {
ServiceHotp(
enabled = state.hotpCounterEnabled,
onClick = { onIncrementCounterClick?.invoke() }
)
}
}
} else {
Box(
Modifier
.size(56.dp)
.clip(CircleShape)
.clickable { onRevealClick?.invoke() }
) {
Icon(
painter = TwIcons.Eye,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.align(Alignment.Center),
tint = TwTheme.color.iconTint
)
}
}
@ -105,5 +154,11 @@ fun DsServiceModal(
@Preview
@Composable
private fun Preview() {
DsServiceModal(state = ServicePreview)
DsServiceModal(state = ServicePreview.copy(revealed = true), hideCodes = true)
}
@Preview
@Composable
private fun PreviewHidden() {
DsServiceModal(state = ServicePreview.copy(revealed = false), hideCodes = true)
}

View File

@ -21,6 +21,7 @@ data class ServiceState(
val labelText: String?,
val labelColor: Color,
val badgeColor: Color = Color.Unspecified,
val revealed: Boolean,
) {
fun copyToClipboard(activity: Activity, showNextToken: Boolean) {
@ -62,6 +63,7 @@ data class ServiceState(
labelText = null,
labelColor = Color.Unspecified,
badgeColor = Color.Unspecified,
revealed = true,
)
}
}

View File

@ -78,7 +78,7 @@ internal fun ServiceCode(
}
}
private fun String.formatCode(): String {
internal fun String.formatCode(): String {
if (isEmpty()) return ""
return when (this.length) {

View File

@ -18,10 +18,11 @@ import com.twofasapp.designsystem.common.TwIcon
@Composable
internal fun ServiceHotp(
enabled: Boolean = true,
onClick: () -> Unit = {}
onClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
Box(modifier = Modifier
Box(modifier = modifier
.size(56.dp)
.clip(CircleShape)
.clickable(enabled) { onClick() }

View File

@ -139,6 +139,8 @@ class Strings(c: Context) {
val settingsShowBackupNoticeConfirmBody = c.getString(R.string.settings__gd_sync_disable_confirm)
val settingsSendCrashes = c.getString(R.string.settings__enable_crashlytics)
val settingsSendCrashesBody = c.getString(R.string.settings__enable_crashlytics_description)
val settingsHideCodes = c.getString(R.string.settings__hide_tokens_title)
val settingsHideCodesBody = c.getString(R.string.settings__hide_tokens_description)
val backupSyncNotice = c.getString(R.string.backup__reminder_msg)
val backupSyncCta = c.getString(R.string.backup__reminder_cta)

View File

@ -30,4 +30,5 @@ interface ServicesRepository {
suspend fun addService(service: Service): Long
fun observeAddServiceAdvancedExpanded(): Flow<Boolean>
fun pushAddServiceAdvancedExpanded(expanded: Boolean)
suspend fun revealService(id: Long)
}

View File

@ -286,4 +286,10 @@ internal class ServicesRepositoryImpl(
override fun pushAddServiceAdvancedExpanded(expanded: Boolean) {
local.pushAddServiceAdvancedExpanded(expanded)
}
override suspend fun revealService(id: Long) {
withContext(dispatchers.io) {
local.revealService(id)
}
}
}

View File

@ -31,6 +31,7 @@ data class Service(
val source: Source,
val assignedDomains: List<String> = emptyList(),
val backupSyncStatus: BackupSyncStatus,
val revealTimestamp: Long? = null,
) {
data class Code(
val current: String,

View File

@ -176,151 +176,15 @@ internal class ServicesLocalSource(
return addServiceAdvancedExpanded
}
fun pushAddServiceAdvancedExpanded(expanded: Boolean) {
fun pushAddServiceAdvancedExpanded(expanded: Boolean) {
addServiceAdvancedExpanded.tryEmit(expanded)
}
//
// fun select(): Single<List<ServiceDto>> {
// return dao.legacySelect()
// .map { list ->
// list.map { local ->
// ServiceDto(
// id = local.id,
// name = local.name,
// secret = local.secret,
// authType = local.authType?.let { ServiceDto.AuthType.valueOf(it) } ?: ServiceDto.AuthType.TOTP,
// otpLabel = local.otpLabel,
// otpAccount = local.otpAccount,
// otpIssuer = local.otpIssuer,
// otpDigits = local.otpDigits,
// otpPeriod = local.otpPeriod,
// otpAlgorithm = local.otpAlgorithm,
// hotpCounter = local.hotpCounter,
// backupSyncStatus = BackupSyncStatus.valueOf(local.backupSyncStatus),
// updatedAt = local.updatedAt,
// badge = local.badgeColor?.let { ServiceDto.Badge(Tint.valueOf(it)) },
// selectedImageType = local.selectedImageType?.let {
// when (it) {
// "Brand" -> ServiceDto.ImageType.IconCollection
// "Label" -> ServiceDto.ImageType.Label
// else -> ServiceDto.ImageType.IconCollection
// }
// } ?: ServiceDto.ImageType.IconCollection,
// labelText = local.labelText,
// labelBackgroundColor = local.labelBackgroundColor?.let { color -> Tint.valueOf(color) },
// iconCollectionId = local.iconCollectionId ?: ServiceIcons.defaultCollectionId,
// groupId = local.groupId,
// isDeleted = local.isDeleted,
// assignedDomains = local.assignedDomains.orEmpty(),
// serviceTypeId = local.serviceTypeId,
// source = local.source?.let { ServiceDto.Source.valueOf(it) } ?: ServiceDto.Source.Manual
// )
// }
// }
// }
//
// fun observe(): Flowable<List<ServiceDto>> {
// return dao.legacyObserve()
// .map { list ->
// list.map { local ->
// ServiceDto(
// id = local.id,
// name = local.name,
// secret = local.secret,
// authType = local.authType?.let { ServiceDto.AuthType.valueOf(it) } ?: ServiceDto.AuthType.TOTP,
// otpLabel = local.otpLabel,
// otpAccount = local.otpAccount,
// otpIssuer = local.otpIssuer,
// otpDigits = local.otpDigits,
// otpPeriod = local.otpPeriod,
// otpAlgorithm = local.otpAlgorithm,
// hotpCounter = local.hotpCounter,
// backupSyncStatus = BackupSyncStatus.valueOf(local.backupSyncStatus),
// updatedAt = local.updatedAt,
// badge = local.badgeColor?.let { ServiceDto.Badge(Tint.valueOf(it)) },
// selectedImageType = local.selectedImageType?.let {
// when (it) {
// "Brand" -> ServiceDto.ImageType.IconCollection
// "Label" -> ServiceDto.ImageType.Label
// else -> ServiceDto.ImageType.IconCollection
// }
// } ?: ServiceDto.ImageType.IconCollection,
// labelText = local.labelText,
// labelBackgroundColor = local.labelBackgroundColor?.let { color -> Tint.valueOf(color) },
// iconCollectionId = local.iconCollectionId ?: ServiceIcons.defaultCollectionId,
// groupId = local.groupId,
// isDeleted = local.isDeleted,
// assignedDomains = local.assignedDomains.orEmpty(),
// serviceTypeId = local.serviceTypeId,
// source = local.source?.let { ServiceDto.Source.valueOf(it) } ?: ServiceDto.Source.Manual
// )
// }
// }
// }
//
// fun insertService(service: ServiceDto): Single<Long> {
// Timber.d("InsertService: $service")
// return dao.legacyInsert(
// ServiceEntity(
// id = 0,
// name = service.name,
// secret = service.secret.removeWhiteCharacters(),
// serviceTypeId = service.serviceTypeId,
// iconCollectionId = service.iconCollectionId,
// source = service.source.name,
// otpLink = service.otpLink,
// otpLabel = service.otpLabel,
// otpAccount = service.otpAccount,
// otpIssuer = service.otpIssuer,
// otpDigits = service.getDigits(),
// otpPeriod = service.getPeriod(),
// otpAlgorithm = service.getAlgorithm(),
// backupSyncStatus = service.backupSyncStatus.name,
// updatedAt = service.updatedAt,
// badgeColor = service.badge?.color?.name,
// selectedImageType = service.selectedImageType.name,
// labelText = service.labelText,
// labelBackgroundColor = service.labelBackgroundColor?.name,
// groupId = service.groupId,
// isDeleted = service.isDeleted,
// authType = service.authType.name,
// hotpCounter = service.hotpCounter,
// assignedDomains = service.assignedDomains
// )
// )
// }
//
// fun updateService(vararg services: ServiceDto): Completable {
// Timber.d("UpdateServices: ${services.toList()}")
// return dao.legacyUpdate(
// *services.map {
// ServiceEntity(
// id = it.id,
// name = it.name,
// secret = it.secret,
// serviceTypeId = it.serviceTypeId,
// iconCollectionId = it.iconCollectionId,
// source = it.source.name,
// otpLink = it.otpLink,
// otpLabel = it.otpLabel,
// otpAccount = it.otpAccount,
// otpIssuer = it.otpIssuer,
// otpDigits = it.getDigits(),
// otpPeriod = it.getPeriod(),
// otpAlgorithm = it.getAlgorithm(),
// backupSyncStatus = it.backupSyncStatus.name,
// updatedAt = it.updatedAt,
// badgeColor = it.badge?.color?.name,
// selectedImageType = it.selectedImageType.name,
// labelText = it.labelText,
// labelBackgroundColor = it.labelBackgroundColor?.name,
// groupId = it.groupId,
// isDeleted = it.isDeleted,
// authType = it.authType.name,
// hotpCounter = it.hotpCounter,
// assignedDomains = it.assignedDomains,
// )
// }.toTypedArray()
// )
// }
suspend fun revealService(id: Long) {
dao.update(
dao.select(id).copy(
revealTimestamp = System.currentTimeMillis(),
)
)
}
}

View File

@ -32,5 +32,6 @@ data class ServiceEntity(
@ColumnInfo(name = "authType") val authType: String?,
@ColumnInfo(name = "hotpCounter") val hotpCounter: Int?,
@ColumnInfo(name = "hotpCounterTimestamp") val hotpCounterTimestamp: Long?,
@ColumnInfo(name = "revealTimestamp") val revealTimestamp: Long?,
@ColumnInfo(name = "assignedDomains") val assignedDomains: List<String>?,
)

View File

@ -47,7 +47,8 @@ internal fun ServiceEntity.asDomain(): Service {
updatedAt = updatedAt,
source = source?.let { Service.Source.valueOf(it) } ?: Service.Source.Manual,
assignedDomains = assignedDomains.orEmpty(),
backupSyncStatus = BackupSyncStatus.valueOf(backupSyncStatus)
backupSyncStatus = BackupSyncStatus.valueOf(backupSyncStatus),
revealTimestamp = revealTimestamp,
)
}
@ -77,7 +78,8 @@ internal fun Service.asEntity(): ServiceEntity {
authType = authType.name,
hotpCounter = hotpCounter,
hotpCounterTimestamp = hotpCounterTimestamp,
assignedDomains = assignedDomains
assignedDomains = assignedDomains,
revealTimestamp = revealTimestamp,
)
}

View File

@ -17,4 +17,5 @@ interface SettingsRepository {
suspend fun setShowBackupNotice(showBackupNotice: Boolean)
suspend fun setSendCrashLogs(sendCrashLogs: Boolean)
suspend fun setAllowScreenshots(allow: Boolean)
suspend fun setHideCodes(hideCodes: Boolean)
}

View File

@ -69,4 +69,10 @@ internal class SettingsRepositoryImpl(
local.setAllowScreenshots(allow)
}
}
override suspend fun setHideCodes(hideCodes: Boolean) {
withContext(dispatchers.io) {
local.setHideCodes(hideCodes)
}
}
}

View File

@ -9,4 +9,5 @@ data class AppSettings(
val selectedTheme: SelectedTheme = SelectedTheme.Auto,
val servicesStyle: ServicesStyle = ServicesStyle.Default,
val servicesSort: ServicesSort = ServicesSort.Manual,
val hideCodes: Boolean = false,
)

View File

@ -25,6 +25,7 @@ internal class SettingsLocalSource(
private const val KeyAutoFocusSearch = "autoFocusSearch"
private const val KeySendCrashLogs = "sendCrashLogs"
private const val KeyAllowScreenshots = "allowScreenshots"
private const val KeyHideCodes = "hideCodes"
}
private val appSettingsFlow: MutableStateFlow<AppSettings> by lazy {
@ -49,6 +50,7 @@ internal class SettingsLocalSource(
selectedTheme = preferences.getString(KeySelectedTheme)?.let { SelectedTheme.valueOf(it) } ?: SelectedTheme.Auto,
servicesStyle = preferences.getString(KeyServicesStyle)?.let { ServicesStyle.valueOf(it) } ?: ServicesStyle.Default,
servicesSort = preferences.getString(KeyServicesSort)?.let { ServicesSort.valueOf(it) } ?: ServicesSort.Manual,
hideCodes = preferences.getBoolean(KeyHideCodes) ?: false,
)
}
@ -91,4 +93,9 @@ internal class SettingsLocalSource(
appSettingsFlow.update { it.copy(allowScreenshots = allow) }
preferences.putBoolean(KeyAllowScreenshots, allow)
}
fun setHideCodes(hideCodes: Boolean) {
appSettingsFlow.update { it.copy(hideCodes = hideCodes) }
preferences.putBoolean(KeyHideCodes, hideCodes)
}
}

View File

@ -40,6 +40,7 @@ internal fun AppSettingsRoute(
onShowNextTokenToggle = { viewModel.toggleShowNextToken() },
onShowBackupNoticeToggle = { viewModel.toggleShowBackupNotice() },
onAutoFocusSearchToggle = { viewModel.toggleAutoFocusSearch() },
onHideCodesToggle = { viewModel.toggleHideTokens() }
)
}
@ -52,6 +53,7 @@ private fun AppSettingsScreen(
onShowNextTokenToggle: () -> Unit,
onShowBackupNoticeToggle: () -> Unit,
onAutoFocusSearchToggle: () -> Unit,
onHideCodesToggle: () -> Unit,
) {
val activity = LocalContext.currentActivity
var showThemeDialog by remember { mutableStateOf(false) }
@ -92,26 +94,27 @@ private fun AppSettingsScreen(
item {
SettingsSwitch(
title = TwLocale.strings.settingsShowNextCode,
checked = uiState.appSettings.showNextCode,
onCheckedChange = { onShowNextTokenToggle() },
subtitle = TwLocale.strings.settingsShowNextCodeBody,
icon = TwIcons.NextToken,
checked = uiState.appSettings.showNextCode,
onCheckedChange = { onShowNextTokenToggle() },
)
}
item {
SettingsSwitch(
title = TwLocale.strings.settingsAutoFocusSearch,
checked = uiState.appSettings.autoFocusSearch,
onCheckedChange = { onAutoFocusSearchToggle() },
subtitle = TwLocale.strings.settingsAutoFocusSearchBody,
icon = TwIcons.Search,
checked = uiState.appSettings.autoFocusSearch,
onCheckedChange = { onAutoFocusSearchToggle() },
)
}
item {
SettingsSwitch(
title = TwLocale.strings.settingsShowBackupNotice,
icon = TwIcons.CloudOff,
checked = uiState.appSettings.showBackupNotice,
onCheckedChange = { checked ->
if (checked.not()) {
@ -120,7 +123,16 @@ private fun AppSettingsScreen(
onShowBackupNoticeToggle()
}
},
icon = TwIcons.CloudOff,
)
}
item {
SettingsSwitch(
title = TwLocale.strings.settingsHideCodes,
subtitle = TwLocale.strings.settingsHideCodesBody,
icon = TwIcons.Eye,
checked = uiState.appSettings.hideCodes,
onCheckedChange = { onHideCodesToggle() },
)
}
}

View File

@ -64,4 +64,10 @@ internal class AppSettingsViewModel(
fun consumeEvent(event: AppSettingsUiEvent) {
uiState.update { it.copy(events = it.events.minus(event)) }
}
fun toggleHideTokens() {
launchScoped {
settingsRepository.setHideCodes(uiState.value.appSettings.hideCodes.not())
}
}
}

View File

@ -1,6 +1,5 @@
package com.twofasapp.feature.home.ui.services
import android.Manifest
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Animatable
import androidx.compose.animation.animateContentSize
@ -47,7 +46,6 @@ import com.twofasapp.data.services.domain.Service
import com.twofasapp.data.session.domain.ServicesSort
import com.twofasapp.data.session.domain.ServicesStyle
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.common.RequestPermission
import com.twofasapp.designsystem.common.TwEmptyScreen
import com.twofasapp.designsystem.common.TwOutlinedButton
import com.twofasapp.designsystem.common.isScrollingUp
@ -108,7 +106,8 @@ internal fun ServicesRoute(
onSearchFocusChange = { viewModel.searchFocused(it) },
onOpenBackupClick = { listener.openBackup(activity) },
onDismissSyncReminderClick = { viewModel.dismissSyncReminder() },
onIncrementHotpCounterClick = { viewModel.incrementHotpCounter(it) }
onIncrementHotpCounterClick = { viewModel.incrementHotpCounter(it) },
onRevealClick = { viewModel.reveal(it) }
)
}
@ -135,6 +134,7 @@ private fun ServicesScreen(
onOpenBackupClick: () -> Unit = {},
onDismissSyncReminderClick: () -> Unit = {},
onIncrementHotpCounterClick: (Service) -> Unit = {},
onRevealClick: (Service) -> Unit = {},
) {
val focusRequester = remember { FocusRequester() }
@ -281,7 +281,7 @@ private fun ServicesScreen(
isVisible = uiState.isLoading.not(),
isExtendedVisible = uiState.totalServices == 0,
isNormalVisible = reorderableState.listState.isScrollingUp(),
onClick = { listener.openAddServiceModal() },
onClick = { listener.openAddServiceModal() },
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
@ -432,6 +432,7 @@ private fun ServicesScreen(
},
editMode = uiState.isInEditMode,
showNextCode = uiState.appSettings.showNextCode,
hideCodes = uiState.appSettings.hideCodes,
containerColor = if (recentlyAddedService == service.id) {
serviceContainerColorBlinking.value
} else {
@ -439,16 +440,13 @@ private fun ServicesScreen(
},
dragHandleVisible = uiState.appSettings.servicesSort == ServicesSort.Manual,
dragModifier = Modifier.detectReorder(state = reorderableState),
onClick = {
state.copyToClipboard(activity, uiState.appSettings.showNextCode)
},
onClick = { state.copyToClipboard(activity, uiState.appSettings.showNextCode) },
onLongClick = {
keyboardController?.hide()
listener.openFocusServiceModal(service.id)
},
onIncrementCounterClick = {
onIncrementHotpCounterClick(service)
}
onIncrementCounterClick = { onIncrementHotpCounterClick(service) },
onRevealClick = { onRevealClick(service) }
)
}
}

View File

@ -239,6 +239,14 @@ internal class ServicesViewModel(
}
}
fun reveal(service: Service) {
launchScoped {
servicesRepository.revealService(
id = service.id,
)
}
}
data class CombinedResult(
val groups: List<Group>,
val services: List<Service>,

View File

@ -31,7 +31,8 @@ fun Service.asState(): ServiceState {
iconDark = iconDark,
labelText = labelText,
labelColor = labelColor.asState(),
badgeColor = badgeColor.asState()
badgeColor = badgeColor.asState(),
revealed = revealTimestamp?.let { it + 10000L > System.currentTimeMillis() } ?: false,
)
}

View File

@ -5,7 +5,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -71,8 +70,10 @@ internal fun AddServiceSuccessScreen(
DsServiceModal(
state = service.asState(),
showNextCode = uiState.showNextCode,
hideCodes = uiState.hideCodes,
containerColor = TwTheme.color.surface,
onIncrementCounterClick = { viewModel.incrementHotpCounter(service) },
onRevealClick = { viewModel.reveal(service) }
)
}

View File

@ -5,4 +5,5 @@ import com.twofasapp.data.services.domain.Service
internal data class AddServiceSuccessUiState(
val service: Service? = null,
val showNextCode: Boolean = false,
val hideCodes: Boolean = false,
)

View File

@ -9,7 +9,6 @@ import com.twofasapp.data.services.domain.Service
import com.twofasapp.data.session.SettingsRepository
import com.twofasapp.feature.home.ui.services.add.NavArg
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.update
internal class AddServiceSuccessViewModel(
@ -35,9 +34,13 @@ internal class AddServiceSuccessViewModel(
launchScoped {
settingsRepository.observeAppSettings()
.distinctUntilChangedBy { it.showNextCode }
.collect { settings ->
uiState.update { it.copy(showNextCode = settings.showNextCode) }
uiState.update {
it.copy(
showNextCode = settings.showNextCode,
hideCodes = settings.hideCodes,
)
}
}
}
}
@ -45,4 +48,8 @@ internal class AddServiceSuccessViewModel(
fun incrementHotpCounter(service: Service) {
launchScoped { servicesRepository.incrementHotpCounter(service) }
}
fun reveal(service: Service) {
launchScoped { servicesRepository.revealService(service.id) }
}
}

View File

@ -49,7 +49,8 @@ fun FocusServiceModal(
iconDark = "",
labelText = null,
labelColor = Color.Unspecified,
badgeColor = Color.Unspecified
badgeColor = Color.Unspecified,
revealed = true,
)
Modal {
@ -59,11 +60,12 @@ fun FocusServiceModal(
DsServiceModal(
state = serviceState,
showNextCode = uiState.showNextCode,
hideCodes = uiState.hideCodes,
containerColor = TwTheme.color.surface,
onIncrementCounterClick = { viewModel.incrementCounter() }
onIncrementCounterClick = { viewModel.incrementCounter() },
onRevealClick = { viewModel.reveal() }
)
SettingsDivider()
ModalList {

View File

@ -5,4 +5,5 @@ import com.twofasapp.data.services.domain.Service
internal data class FocusServiceUiState(
val service: Service? = null,
val showNextCode: Boolean = false,
val hideCodes: Boolean = false,
)

View File

@ -7,7 +7,6 @@ import com.twofasapp.common.ktx.launchScoped
import com.twofasapp.data.services.ServicesRepository
import com.twofasapp.data.session.SettingsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
@ -31,9 +30,13 @@ class FocusServiceViewModel(
launchScoped {
settingsRepository.observeAppSettings()
.distinctUntilChangedBy { it.showNextCode }
.collect { settings ->
uiState.update { it.copy(showNextCode = settings.showNextCode) }
uiState.update {
it.copy(
showNextCode = settings.showNextCode,
hideCodes = settings.hideCodes,
)
}
}
}
}
@ -43,4 +46,8 @@ class FocusServiceViewModel(
launchScoped { servicesRepository.incrementHotpCounter(it) }
}
}
fun reveal() {
launchScoped { servicesRepository.revealService(serviceId) }
}
}

View File

@ -81,6 +81,7 @@ private fun TrashScreen(
iconDark = it.iconDark,
labelText = it.labelText,
labelColor = it.labelColor.asState(),
revealed = true,
),
modifier = Modifier
.fillMaxWidth()

View File

@ -0,0 +1,284 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "adbe89b487f593074103911e789d6bf3",
"entities": [
{
"tableName": "local_services",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `secret` TEXT NOT NULL, `serviceTypeId` TEXT, `iconCollectionId` TEXT, `source` TEXT, `otpLink` TEXT, `otpLabel` TEXT, `otpAccount` TEXT, `otpIssuer` TEXT, `otpDigits` INTEGER, `otpPeriod` INTEGER, `otpAlgorithm` TEXT, `backupSyncStatus` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `badgeColor` TEXT, `selectedImageType` TEXT, `labelText` TEXT, `labelBackgroundColor` TEXT, `groupId` TEXT, `isDeleted` INTEGER, `authType` TEXT, `hotpCounter` INTEGER, `hotpCounterTimestamp` INTEGER, `revealTimestamp` INTEGER, `assignedDomains` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "secret",
"columnName": "secret",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "serviceTypeId",
"columnName": "serviceTypeId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "iconCollectionId",
"columnName": "iconCollectionId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "otpLink",
"columnName": "otpLink",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "otpLabel",
"columnName": "otpLabel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "otpAccount",
"columnName": "otpAccount",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "otpIssuer",
"columnName": "otpIssuer",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "otpDigits",
"columnName": "otpDigits",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "otpPeriod",
"columnName": "otpPeriod",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "otpAlgorithm",
"columnName": "otpAlgorithm",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "backupSyncStatus",
"columnName": "backupSyncStatus",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "badgeColor",
"columnName": "badgeColor",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "selectedImageType",
"columnName": "selectedImageType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "labelText",
"columnName": "labelText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "labelBackgroundColor",
"columnName": "labelBackgroundColor",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDeleted",
"columnName": "isDeleted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "hotpCounter",
"columnName": "hotpCounter",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "hotpCounterTimestamp",
"columnName": "hotpCounterTimestamp",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "revealTimestamp",
"columnName": "revealTimestamp",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "assignedDomains",
"columnName": "assignedDomains",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "paired_browsers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `extensionPublicKey` TEXT NOT NULL, `pairedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionPublicKey",
"columnName": "extensionPublicKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pairedAt",
"columnName": "pairedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "notifications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category` TEXT NOT NULL, `link` TEXT NOT NULL, `message` TEXT NOT NULL, `publishTime` INTEGER NOT NULL, `push` INTEGER NOT NULL, `platform` TEXT NOT NULL, `isRead` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "publishTime",
"columnName": "publishTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "push",
"columnName": "push",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "platform",
"columnName": "platform",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
}
],
"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, 'adbe89b487f593074103911e789d6bf3')"
]
}
}

View File

@ -36,7 +36,7 @@ import java.text.Normalizer
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
companion object {
const val DB_VERSION = 11
const val DB_VERSION = 12
}
abstract fun serviceDao(): ServiceDao
@ -244,6 +244,12 @@ val MIGRATION_10_11 = object : Migration(10, 11) {
}
}
val MIGRATION_11_12 = object : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE local_services ADD COLUMN revealTimestamp INTEGER")
}
}
private fun Cursor.long(column: String): Long {
return getLong(columnNames.indexOf(column))
}

View File

@ -34,6 +34,7 @@ class PersistenceModule : KoinModule {
MIGRATION_6_7,
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
)
if (get<AppBuild>().isDebuggable.not()) {

View File

@ -140,7 +140,8 @@ internal class ServicesLocalDataImpl(
authType = service.authType.name,
hotpCounter = service.hotpCounter,
hotpCounterTimestamp = null,
assignedDomains = service.assignedDomains
assignedDomains = service.assignedDomains,
revealTimestamp = null,
)
).also {
Timber.d("InsertService: Inserted with id $it")
@ -188,6 +189,7 @@ internal class ServicesLocalDataImpl(
hotpCounter = it.hotpCounter,
hotpCounterTimestamp = null,
assignedDomains = it.assignedDomains,
revealTimestamp = null,
)
}.toTypedArray()
)

View File

@ -68,6 +68,7 @@ internal fun Service.toEntity() = com.twofasapp.data.services.local.model.Servic
hotpCounter = otp.hotpCounter,
hotpCounterTimestamp = null,
assignedDomains = assignedDomains,
revealTimestamp = null,
)
internal fun Service.toDeprecatedDto() = ServiceDto(