app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

HarvestCirclePresenter.kt (33641B)


      1 package org.harvestcircle.application
      2 
      3 import kotlinx.coroutines.CancellationException
      4 import kotlinx.coroutines.CoroutineScope
      5 import kotlinx.coroutines.Job
      6 import kotlinx.coroutines.NonCancellable
      7 import kotlinx.coroutines.cancelAndJoin
      8 import kotlinx.coroutines.delay
      9 import kotlinx.coroutines.flow.MutableSharedFlow
     10 import kotlinx.coroutines.flow.MutableStateFlow
     11 import kotlinx.coroutines.flow.SharedFlow
     12 import kotlinx.coroutines.flow.StateFlow
     13 import kotlinx.coroutines.flow.asSharedFlow
     14 import kotlinx.coroutines.flow.asStateFlow
     15 import kotlinx.coroutines.flow.collect
     16 import kotlinx.coroutines.flow.update
     17 import kotlinx.coroutines.launch
     18 import kotlinx.coroutines.sync.Mutex
     19 import kotlinx.coroutines.sync.withLock
     20 import kotlinx.coroutines.withContext
     21 
     22 class HarvestCirclePresenter(
     23     private val runtime: HarvestCircleRuntime,
     24     private val scope: CoroutineScope,
     25     private val clock: ApplicationClock,
     26     private val operationIds: OperationIdSource,
     27 ) : IdentityPresentationPort {
     28     private val mutableState = MutableStateFlow(HarvestCirclePresenterState(runtime.currentSnapshot()))
     29     private val mutableEffects = MutableSharedFlow<HarvestCircleEffect>(extraBufferCapacity = EFFECT_BUFFER_CAPACITY)
     30     private var subscriptionJob: Job? = null
     31     private var commandJob: Job? = null
     32     private var pendingRecovery: GeneratedIdentityRecovery? = null
     33     private var pendingRemoval: PendingRemovalLease? = null
     34     private var pendingRetry: PendingRetry? = null
     35     private val removalMutex = Mutex()
     36     private val closeMutex = Mutex()
     37     private var closeReceipt: ShutdownReceipt? = null
     38     private var closePhase = PresenterClosePhase.Open
     39     private val closed: Boolean
     40         get() = closePhase != PresenterClosePhase.Open
     41 
     42     override val state: StateFlow<HarvestCirclePresenterState> = mutableState.asStateFlow()
     43     val effects: SharedFlow<HarvestCircleEffect> = mutableEffects.asSharedFlow()
     44     val buildInfo: BuildInfo = runtime.buildInfo
     45 
     46     init {
     47         subscriptionJob =
     48             scope.launch {
     49                 try {
     50                     runtime.changes().collect(::acceptChange)
     51                 } catch (error: CancellationException) {
     52                     throw error
     53                 } catch (error: Exception) {
     54                     if (!closed) acceptFailure(error, null)
     55                 }
     56             }
     57         launchOperation(requireReady = false) {
     58             acceptSnapshot(runtime.bootstrap())
     59         }
     60     }
     61 
     62     override fun dispatch(intent: HarvestCircleIntent) {
     63         when (intent) {
     64             is HarvestCircleIntent.EditImportDraft -> editImportDraft(intent)
     65             HarvestCircleIntent.ChooseCreateIdentity -> updateState { copy(identityEntryMode = IdentityEntryMode.CREATE, problem = null) }
     66             HarvestCircleIntent.ChooseImportIdentity -> updateState { copy(identityEntryMode = IdentityEntryMode.IMPORT, problem = null) }
     67             HarvestCircleIntent.CancelIdentityEntry ->
     68                 clearImportDraft {
     69                     copy(
     70                         identityEntryMode = IdentityEntryMode.CHOICE,
     71                         problem = null,
     72                     )
     73                 }
     74             HarvestCircleIntent.GenerateIdentity -> prepareIdentity()
     75             HarvestCircleIntent.AcknowledgeGeneratedRecovery -> acknowledgeRecovery()
     76             HarvestCircleIntent.CancelGeneratedRecovery -> cancelRecovery()
     77             HarvestCircleIntent.ImportIdentity -> importIdentity()
     78             is HarvestCircleIntent.SelectIdentity -> executeIntent(intent)
     79             is HarvestCircleIntent.ActivateIdentity -> executeIntent(intent)
     80             HarvestCircleIntent.SignOut -> executeIntent(intent)
     81             HarvestCircleIntent.ShowIdentityChooser -> updateState { copy(identityChooserVisible = true, problem = null) }
     82             HarvestCircleIntent.HideIdentityChooser -> updateState { copy(identityChooserVisible = false) }
     83             HarvestCircleIntent.RefreshActiveProfile -> executeIntent(intent)
     84             HarvestCircleIntent.RetryLastCommand -> retryLastCommand()
     85             is HarvestCircleIntent.RequestIdentityRemoval -> requestIdentityRemoval(intent)
     86             is HarvestCircleIntent.CancelIdentityRemoval -> cancelIdentityRemoval(intent)
     87             is HarvestCircleIntent.ConfirmIdentityRemoval -> confirmIdentityRemoval(intent)
     88             HarvestCircleIntent.DismissProblem -> updateState { copy(problem = null) }
     89         }
     90     }
     91 
     92     suspend fun close(): ShutdownReceipt? =
     93         closeMutex.withLock {
     94             closeReceipt?.let { return@withLock it }
     95             if (closePhase == PresenterClosePhase.Open) {
     96                 closePhase = PresenterClosePhase.Closing
     97                 updateState { copy(route = HarvestCircleRoute.SHUTTING_DOWN, busy = true, problem = null) }
     98             }
     99             if (closePhase == PresenterClosePhase.Closing) {
    100                 commandJob?.cancelAndJoin()
    101                 subscriptionJob?.cancelAndJoin()
    102                 releaseRecovery()
    103                 clearImportDraft()
    104                 transferRemovalToShutdown()
    105                 closePhase = PresenterClosePhase.TransferredToShutdown
    106             }
    107             return try {
    108                 runtime.shutdown().also { receipt ->
    109                     if (receipt.closed) {
    110                         closeReceipt = receipt
    111                         closePhase = PresenterClosePhase.Closed
    112                     }
    113                     updateState {
    114                         copy(
    115                             route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL,
    116                             busy = false,
    117                             problem = if (receipt.closed) null else "The application could not shut down safely.",
    118                         )
    119                     }
    120                 }
    121             } catch (error: CancellationException) {
    122                 throw error
    123             } catch (error: Exception) {
    124                 acceptFailure(error, null)
    125                 updateState { copy(route = HarvestCircleRoute.FATAL, busy = false) }
    126                 null
    127             }
    128         }
    129 
    130     private fun editImportDraft(intent: HarvestCircleIntent.EditImportDraft) {
    131         val incoming = intent.takeDraft() ?: return
    132         if (closed) {
    133             incoming.clear()
    134             rejectUnavailable("The application runtime is closed.")
    135             return
    136         }
    137         while (true) {
    138             val current = mutableState.value
    139             val updated = current.copy(importDraft = incoming, lastProblem = null, problem = null)
    140             if (!mutableState.compareAndSet(current, updated)) continue
    141             if (current.importDraft !== incoming) current.importDraft.clear()
    142             return
    143         }
    144     }
    145 
    146     private fun prepareIdentity() {
    147         launchOperation {
    148             pendingRecovery?.let { previous ->
    149                 runtime.execute(ApplicationCommand.CancelGeneratedIdentity(previous.requestId))
    150                 previous.backup.clear()
    151             }
    152             val recovery = runtime.prepareLocalIdentity()
    153             pendingRecovery = recovery
    154             updateState { copy(generatedKeyBackup = recovery.backup) }
    155         }
    156     }
    157 
    158     private fun acknowledgeRecovery() {
    159         val recovery = pendingRecovery ?: return rejectUnavailable("Generated-key recovery is not available.")
    160         val operationId = operationIds.next()
    161         launchOperation(operationId = operationId) {
    162             try {
    163                 val result =
    164                     runtime.execute(
    165                         ApplicationCommand.AcknowledgeGeneratedIdentity(
    166                             recovery.requestId,
    167                             requestContext(operationId),
    168                         ),
    169                     )
    170                 acceptResult(result, operationId)
    171                 updateState { copy(identityEntryMode = IdentityEntryMode.CHOICE) }
    172             } finally {
    173                 releaseRecovery()
    174             }
    175         }
    176     }
    177 
    178     private fun cancelRecovery() {
    179         val recovery = pendingRecovery ?: return rejectUnavailable("Generated-key recovery is not available.")
    180         launchOperation {
    181             try {
    182                 runtime.execute(ApplicationCommand.CancelGeneratedIdentity(recovery.requestId))
    183             } finally {
    184                 releaseRecovery()
    185             }
    186         }
    187     }
    188 
    189     private fun importIdentity() {
    190         val draft = state.value.importDraft
    191         val operationId = operationIds.next()
    192         launchOperation(
    193             operationId = operationId,
    194             onAccepted = { detachImportDraft(draft) },
    195         ) {
    196             var input: SecretKeyInput? = null
    197             try {
    198                 input = SecretKeyInput.from(draft.take())
    199                 val command = ApplicationCommand.ImportLocalIdentity(input, requestContext(operationId))
    200                 acceptResult(runtime.execute(command), operationId)
    201                 updateState { copy(identityEntryMode = IdentityEntryMode.CHOICE) }
    202             } finally {
    203                 input?.clear()
    204                 draft.clear()
    205             }
    206         }
    207     }
    208 
    209     private fun executeIntent(
    210         intent: HarvestCircleIntent,
    211         operationId: OperationId = operationIds.next(),
    212     ) {
    213         val activationTarget = (intent as? HarvestCircleIntent.ActivateIdentity)?.identityId
    214         launchOperation(
    215             operationId = operationId,
    216             onAccepted = {
    217                 activationTarget?.let { target ->
    218                     updateState { copy(activatingIdentityId = target) }
    219                 }
    220             },
    221         ) {
    222             try {
    223                 val command = intent.toApplicationCommand()
    224                 acceptResult(runtime.execute(command), operationId)
    225                 pendingRetry = null
    226                 if (activationTarget != null || intent == HarvestCircleIntent.SignOut) {
    227                     updateState { copy(identityChooserVisible = false) }
    228                 }
    229             } catch (error: Exception) {
    230                 val problem = error.toProblem(operationId)
    231                 pendingRetry = PendingRetry(intent, operationId).takeIf { problem.retryable }
    232                 throw ApplicationFailure(problem)
    233             } finally {
    234                 activationTarget?.let { target ->
    235                     updateState {
    236                         if (activatingIdentityId == target) copy(activatingIdentityId = null) else this
    237                     }
    238                 }
    239             }
    240         }
    241     }
    242 
    243     private fun retryLastCommand() {
    244         val retry = pendingRetry ?: return rejectUnavailable("This action cannot be retried safely.")
    245         executeIntent(retry.intent, retry.operationId)
    246     }
    247 
    248     private fun requestIdentityRemoval(intent: HarvestCircleIntent.RequestIdentityRemoval) {
    249         launchOperation {
    250             releaseCurrentRemovalForReplacement()
    251             updateState {
    252                 copy(
    253                     removalConfirmation = null,
    254                     removalStatus = RemovalStatus.NONE,
    255                 )
    256             }
    257             val request = runtime.requestIdentityRemoval(intent.identityId)
    258             val lease = PendingRemovalLease(request)
    259             val expired = request.isExpired()
    260             removalMutex.withLock {
    261                 pendingRemoval = lease
    262                 if (expired) {
    263                     lease.phase = RemovalLeasePhase.Releasing
    264                     lease.releaseReason = RemovalReleaseReason.Expired
    265                 } else {
    266                     lease.expiryJob = scope.launch { expireRemovalLease(lease) }
    267                 }
    268             }
    269             if (expired) {
    270                 releaseClaimedRemoval(ClaimedRemovalRelease(lease, RemovalReleaseReason.Expired), RemovalStatus.FAILED)
    271                 throw ApplicationFailure(removalExpiredProblem())
    272             }
    273             updateState {
    274                 copy(
    275                     removalConfirmation = request.toConfirmation(),
    276                     removalStatus = RemovalStatus.AWAITING_CONFIRMATION,
    277                 )
    278             }
    279         }
    280     }
    281 
    282     private fun cancelIdentityRemoval(intent: HarvestCircleIntent.CancelIdentityRemoval) {
    283         launchOperation {
    284             val claim =
    285                 claimMatchingRemoval(intent.identityId, intent.requestId, RemovalTerminalAction.Cancel)
    286                     ?: return@launchOperation
    287             releaseClaimedRemoval(
    288                 ClaimedRemovalRelease(claim.lease, claim.releaseReason ?: RemovalReleaseReason.UserCancelled),
    289                 if (claim.expired) RemovalStatus.FAILED else RemovalStatus.NONE,
    290             )
    291             if (claim.expired) {
    292                 throw ApplicationFailure(removalExpiredProblem())
    293             }
    294         }
    295     }
    296 
    297     private fun confirmIdentityRemoval(intent: HarvestCircleIntent.ConfirmIdentityRemoval) {
    298         val operationId = operationIds.next()
    299         launchOperation(operationId = operationId) {
    300             val claim =
    301                 claimMatchingRemoval(intent.identityId, intent.requestId, RemovalTerminalAction.Confirm)
    302                     ?: return@launchOperation
    303             val lease = claim.lease
    304             val request = lease.request
    305             if (claim.expired) {
    306                 releaseClaimedRemoval(ClaimedRemovalRelease(lease, RemovalReleaseReason.Expired), RemovalStatus.FAILED)
    307                 throw ApplicationFailure(removalExpiredProblem(operationId))
    308             }
    309             updateState {
    310                 copy(
    311                     removalConfirmation = null,
    312                     removalStatus = RemovalStatus.CONFIRMING,
    313                 )
    314             }
    315             try {
    316                 val result =
    317                     runtime.execute(
    318                         ApplicationCommand.ConfirmIdentityRemoval(
    319                             request.requestId,
    320                             requestContext(operationId),
    321                         ),
    322                     )
    323                 acceptResult(result, operationId)
    324                 removalMutex.withLock {
    325                     if (pendingRemoval === lease && lease.phase == RemovalLeasePhase.Confirming) {
    326                         lease.phase = RemovalLeasePhase.Confirmed
    327                         pendingRemoval = null
    328                     }
    329                 }
    330                 updateState {
    331                     copy(
    332                         removalConfirmation = null,
    333                         removalStatus = RemovalStatus.COMPLETED,
    334                         lastRemovedIdentityId = request.identityId,
    335                     )
    336                 }
    337                 mutableEffects.tryEmit(HarvestCircleEffect.IdentityRemoved(request.identityId))
    338             } catch (error: CancellationException) {
    339                 withContext(NonCancellable) {
    340                     releaseAfterFailedConfirmation(lease)
    341                 }
    342                 throw error
    343             } catch (error: Exception) {
    344                 releaseAfterFailedConfirmation(lease)
    345                 throw error
    346             }
    347         }
    348     }
    349 
    350     private suspend fun claimMatchingRemoval(
    351         identityId: IdentityId,
    352         requestId: RemovalRequestId,
    353         action: RemovalTerminalAction,
    354     ): ClaimedRemoval? {
    355         var rejection: String? = null
    356         var releaseFailed = false
    357         val claim =
    358             removalMutex.withLock {
    359                 val lease = pendingRemoval
    360                 val confirmation = state.value.removalConfirmation
    361                 if (lease?.phase == RemovalLeasePhase.ReleaseFailed) {
    362                     releaseFailed = true
    363                     return@withLock null
    364                 }
    365                 if (lease == null ||
    366                     lease.phase != RemovalLeasePhase.Open ||
    367                     confirmation == null ||
    368                     state.value.removalStatus != RemovalStatus.AWAITING_CONFIRMATION
    369                 ) {
    370                     rejection = "Identity removal confirmation is not available."
    371                     return@withLock null
    372                 }
    373                 val request = lease.request
    374                 if (request.identityId != identityId ||
    375                     request.requestId != requestId ||
    376                     confirmation.identityId != identityId ||
    377                     confirmation.requestId != requestId
    378                 ) {
    379                     rejection = "Identity removal confirmation does not match the current request."
    380                     return@withLock null
    381                 }
    382                 val expired = request.isExpired()
    383                 val releaseReason =
    384                     when {
    385                         expired -> RemovalReleaseReason.Expired
    386                         action == RemovalTerminalAction.Cancel -> RemovalReleaseReason.UserCancelled
    387                         else -> null
    388                     }
    389                 lease.phase = if (releaseReason == null) RemovalLeasePhase.Confirming else RemovalLeasePhase.Releasing
    390                 lease.releaseReason = releaseReason
    391                 val expiryJob = lease.expiryJob
    392                 lease.expiryJob = null
    393                 ClaimedRemoval(lease, expiryJob, expired, releaseReason)
    394             }
    395         if (releaseFailed) {
    396             acceptFailure(ApplicationFailure(removalReleaseFailedProblem()), null)
    397             return null
    398         }
    399         if (claim == null) {
    400             rejectUnavailable(checkNotNull(rejection))
    401             return null
    402         }
    403         claim.expiryJob?.cancelAndJoin()
    404         return claim
    405     }
    406 
    407     private suspend fun releaseCurrentRemovalForReplacement() {
    408         var releaseFailed = false
    409         val claim =
    410             removalMutex.withLock {
    411                 val lease = pendingRemoval ?: return@withLock null
    412                 if (lease.phase == RemovalLeasePhase.ReleaseFailed) {
    413                     releaseFailed = true
    414                     return@withLock null
    415                 }
    416                 if (lease.phase != RemovalLeasePhase.Open) {
    417                     throw ApplicationFailure(removalReleaseFailedProblem())
    418                 }
    419                 lease.phase = RemovalLeasePhase.Releasing
    420                 lease.releaseReason = RemovalReleaseReason.Replaced
    421                 val expiryJob = lease.expiryJob
    422                 lease.expiryJob = null
    423                 ClaimedRemoval(lease, expiryJob, expired = false, RemovalReleaseReason.Replaced)
    424             }
    425         if (releaseFailed) throw ApplicationFailure(removalReleaseFailedProblem())
    426         claim ?: return
    427         claim.expiryJob?.cancelAndJoin()
    428         releaseClaimedRemoval(
    429             ClaimedRemovalRelease(claim.lease, RemovalReleaseReason.Replaced),
    430             RemovalStatus.NONE,
    431         )
    432     }
    433 
    434     private suspend fun releaseAfterFailedConfirmation(lease: PendingRemovalLease) {
    435         val claimed =
    436             removalMutex.withLock {
    437                 if (pendingRemoval !== lease || lease.phase != RemovalLeasePhase.Confirming) return@withLock false
    438                 lease.phase = RemovalLeasePhase.Releasing
    439                 lease.releaseReason = RemovalReleaseReason.ConfirmFailed
    440                 true
    441             }
    442         if (claimed) {
    443             releaseClaimedRemoval(
    444                 ClaimedRemovalRelease(lease, RemovalReleaseReason.ConfirmFailed),
    445                 RemovalStatus.FAILED,
    446             )
    447         }
    448     }
    449 
    450     private suspend fun releaseClaimedRemoval(
    451         claim: ClaimedRemovalRelease,
    452         resultingStatus: RemovalStatus,
    453     ) {
    454         val lease = claim.lease
    455         val released =
    456             try {
    457                 runtime.cancelIdentityRemoval(lease.request.requestId)
    458             } catch (error: CancellationException) {
    459                 quarantineRemovalLease(claim)
    460                 if (!closed) acceptFailure(ApplicationFailure(removalReleaseFailedProblem()), null)
    461                 throw error
    462             } catch (_: Exception) {
    463                 quarantineRemovalLease(claim)
    464                 throw ApplicationFailure(removalReleaseFailedProblem())
    465             }
    466         if (!released) {
    467             quarantineRemovalLease(claim)
    468             throw ApplicationFailure(removalReleaseFailedProblem())
    469         }
    470         val completed =
    471             removalMutex.withLock {
    472                 if (pendingRemoval !== lease ||
    473                     lease.phase != RemovalLeasePhase.Releasing ||
    474                     lease.releaseReason != claim.reason
    475                 ) {
    476                     return@withLock false
    477                 }
    478                 lease.phase = RemovalLeasePhase.Released
    479                 pendingRemoval = null
    480                 true
    481             }
    482         if (!completed) throw ApplicationFailure(removalReleaseFailedProblem())
    483         updateState {
    484             copy(
    485                 removalConfirmation = null,
    486                 removalStatus = resultingStatus,
    487             )
    488         }
    489     }
    490 
    491     private suspend fun quarantineRemovalLease(claim: ClaimedRemovalRelease) {
    492         val publish =
    493             removalMutex.withLock {
    494                 val lease = claim.lease
    495                 if (pendingRemoval !== lease ||
    496                     lease.phase != RemovalLeasePhase.Releasing ||
    497                     lease.releaseReason != claim.reason
    498                 ) {
    499                     return@withLock false
    500                 }
    501                 lease.phase = RemovalLeasePhase.ReleaseFailed
    502                 closePhase == PresenterClosePhase.Open
    503             }
    504         if (publish) {
    505             updateState {
    506                 copy(
    507                     removalConfirmation = null,
    508                     removalStatus = RemovalStatus.FAILED,
    509                 )
    510             }
    511         }
    512     }
    513 
    514     private suspend fun expireRemovalLease(lease: PendingRemovalLease) {
    515         while (true) {
    516             delay(removalExpiryDelayMillis(lease.request.expiresAt, clock.now()))
    517             var stillOpenBeforeDeadline = false
    518             val claimed =
    519                 removalMutex.withLock {
    520                     if (pendingRemoval !== lease ||
    521                         lease.phase != RemovalLeasePhase.Open ||
    522                         closePhase != PresenterClosePhase.Open
    523                     ) {
    524                         return@withLock false
    525                     }
    526                     if (!lease.request.isExpired()) {
    527                         stillOpenBeforeDeadline = true
    528                         return@withLock false
    529                     }
    530                     lease.phase = RemovalLeasePhase.Releasing
    531                     lease.releaseReason = RemovalReleaseReason.Expired
    532                     true
    533                 }
    534             if (!claimed) {
    535                 if (stillOpenBeforeDeadline) continue
    536                 return
    537             }
    538             updateState { copy(removalConfirmation = null) }
    539             try {
    540                 releaseClaimedRemoval(
    541                     ClaimedRemovalRelease(lease, RemovalReleaseReason.Expired),
    542                     RemovalStatus.FAILED,
    543                 )
    544                 acceptFailure(ApplicationFailure(removalExpiredProblem()), null)
    545             } catch (error: CancellationException) {
    546                 throw error
    547             } catch (error: Exception) {
    548                 acceptFailure(error, null)
    549             }
    550             return
    551         }
    552     }
    553 
    554     private suspend fun transferRemovalToShutdown() {
    555         val expiryJob =
    556             removalMutex.withLock {
    557                 pendingRemoval?.let { lease ->
    558                     lease.expiryJob.also { lease.expiryJob = null }
    559                 }
    560             }
    561         expiryJob?.cancelAndJoin()
    562         removalMutex.withLock {
    563             pendingRemoval?.let { lease ->
    564                 lease.phase = RemovalLeasePhase.TransferredToShutdown
    565                 lease.releaseReason = null
    566             }
    567             pendingRemoval = null
    568         }
    569         updateState { copy(removalConfirmation = null) }
    570     }
    571 
    572     private fun IdentityRemovalRequest.isExpired(): Boolean = expiresAt.value <= clock.now().value
    573 
    574     private fun launchOperation(
    575         requireReady: Boolean = true,
    576         operationId: OperationId? = null,
    577         onAccepted: () -> Unit = {},
    578         operation: suspend () -> Unit,
    579     ) {
    580         if (rejectIfUnavailable(requireReady)) return
    581         updateState {
    582             copy(
    583                 busy = true,
    584                 commandStatus = CommandStatus.RUNNING,
    585                 lastCommandOperationId = operationId ?: lastCommandOperationId,
    586                 problem = null,
    587             )
    588         }
    589         onAccepted()
    590         commandJob =
    591             scope.launch {
    592                 try {
    593                     operation()
    594                     if (state.value.commandStatus == CommandStatus.RUNNING) {
    595                         updateState { copy(commandStatus = CommandStatus.ACCEPTED) }
    596                     }
    597                 } catch (error: CancellationException) {
    598                     throw error
    599                 } catch (error: Exception) {
    600                     acceptFailure(error, operationId)
    601                 } finally {
    602                     updateState { copy(busy = false) }
    603                 }
    604             }
    605     }
    606 
    607     private fun rejectIfUnavailable(requireReady: Boolean): Boolean {
    608         val current = state.value
    609         if (closed) {
    610             updateState {
    611                 copy(commandStatus = CommandStatus.REJECTED_CLOSED, problem = "The application runtime is closed.")
    612             }
    613             return true
    614         }
    615         if (commandJob?.isActive == true) {
    616             updateState {
    617                 copy(commandStatus = CommandStatus.REJECTED_BUSY, problem = "The application is busy. Try again.")
    618             }
    619             return true
    620         }
    621         if (requireReady && current.route !in READY_ROUTES) {
    622             updateState {
    623                 copy(commandStatus = CommandStatus.FAILED_TERMINAL, problem = "The application runtime is not ready for this action.")
    624             }
    625             return true
    626         }
    627         return false
    628     }
    629 
    630     private fun rejectUnavailable(message: String) {
    631         updateState {
    632             copy(
    633                 commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL,
    634                 problem = message,
    635             )
    636         }
    637     }
    638 
    639     private fun acceptResult(
    640         result: ApplicationCommandResult,
    641         operationId: OperationId,
    642     ) {
    643         acceptSnapshot(result.snapshot)
    644         updateState {
    645             copy(
    646                 commandStatus = CommandStatus.ACCEPTED,
    647                 lastCommandOperationId = operationId,
    648                 lastProblem = null,
    649                 problem = null,
    650             )
    651         }
    652     }
    653 
    654     private fun acceptSnapshot(snapshot: ApplicationSnapshot) {
    655         if (snapshot.revision.value >= state.value.snapshot.revision.value) {
    656             updateState { copy(snapshot = snapshot, route = snapshot.toHarvestCircleRoute()) }
    657         }
    658     }
    659 
    660     private fun acceptChange(change: ApplicationChange) {
    661         val acceptedRevision = state.value.snapshot.revision
    662         if (change.snapshot.revision.value <= acceptedRevision.value) return
    663         if (change.previousRevision == acceptedRevision) {
    664             acceptSnapshot(change.snapshot)
    665             return
    666         }
    667         val refreshed = runtime.currentSnapshot()
    668         if (refreshed.revision.value < change.snapshot.revision.value) {
    669             throw ApplicationFailure(
    670                 ApplicationProblem(
    671                     code = ApplicationErrorCode.ObserverRegistrationFailed,
    672                     category = ApplicationErrorCategory.Lifecycle,
    673                     retryable = false,
    674                     recoveryAction = RecoveryAction.RestartApplication,
    675                     operationId = null,
    676                     safeMessage = "Application updates could not be synchronized.",
    677                 ),
    678             )
    679         }
    680         acceptSnapshot(refreshed)
    681     }
    682 
    683     private fun acceptFailure(
    684         error: Throwable,
    685         operationId: OperationId?,
    686     ) {
    687         val problem = error.toProblem(operationId)
    688         updateState {
    689             copy(
    690                 commandStatus = if (problem.retryable) CommandStatus.FAILED_RETRYABLE else CommandStatus.FAILED_TERMINAL,
    691                 lastCommandOperationId = problem.operationId ?: operationId ?: lastCommandOperationId,
    692                 lastProblem = problem,
    693                 problem = problem.safeMessage,
    694             )
    695         }
    696         mutableEffects.tryEmit(HarvestCircleEffect.Problem(problem))
    697     }
    698 
    699     private fun releaseRecovery() {
    700         val recovery = pendingRecovery
    701         pendingRecovery = null
    702         recovery?.backup?.clear()
    703         updateState { copy(generatedKeyBackup = null) }
    704     }
    705 
    706     private fun detachImportDraft(draft: ImportSecretDraft) {
    707         while (true) {
    708             val current = mutableState.value
    709             if (current.importDraft !== draft) return
    710             if (mutableState.compareAndSet(current, current.copy(importDraft = ImportSecretDraft.empty()))) return
    711         }
    712     }
    713 
    714     private fun clearImportDraft(transform: HarvestCirclePresenterState.() -> HarvestCirclePresenterState = { this }) {
    715         while (true) {
    716             val current = mutableState.value
    717             val replacement = ImportSecretDraft.empty()
    718             val updated = current.copy(importDraft = replacement).transform()
    719             if (!mutableState.compareAndSet(current, updated)) {
    720                 replacement.clear()
    721                 continue
    722             }
    723             current.importDraft.clear()
    724             return
    725         }
    726     }
    727 
    728     private fun requestContext(operationId: OperationId): RequestContext =
    729         RequestContext(operationId, state.value.snapshot.revision, COMMAND_DEADLINE_MILLIS)
    730 
    731     private fun updateState(transform: HarvestCirclePresenterState.() -> HarvestCirclePresenterState) {
    732         mutableState.update(transform)
    733     }
    734 }
    735 
    736 private data class PendingRetry(
    737     val intent: HarvestCircleIntent,
    738     val operationId: OperationId,
    739 )
    740 
    741 private class PendingRemovalLease(
    742     val request: IdentityRemovalRequest,
    743     var phase: RemovalLeasePhase = RemovalLeasePhase.Open,
    744     var releaseReason: RemovalReleaseReason? = null,
    745     var expiryJob: Job? = null,
    746 )
    747 
    748 private enum class RemovalLeasePhase {
    749     Open,
    750     Confirming,
    751     Releasing,
    752     Confirmed,
    753     Released,
    754     ReleaseFailed,
    755     TransferredToShutdown,
    756 }
    757 
    758 private enum class RemovalReleaseReason {
    759     Expired,
    760     UserCancelled,
    761     Replaced,
    762     ConfirmFailed,
    763 }
    764 
    765 private enum class RemovalTerminalAction {
    766     Confirm,
    767     Cancel,
    768 }
    769 
    770 private enum class PresenterClosePhase {
    771     Open,
    772     Closing,
    773     TransferredToShutdown,
    774     Closed,
    775 }
    776 
    777 private data class ClaimedRemoval(
    778     val lease: PendingRemovalLease,
    779     val expiryJob: Job?,
    780     val expired: Boolean,
    781     val releaseReason: RemovalReleaseReason?,
    782 )
    783 
    784 private data class ClaimedRemovalRelease(
    785     val lease: PendingRemovalLease,
    786     val reason: RemovalReleaseReason,
    787 )
    788 
    789 private fun IdentityRemovalRequest.toConfirmation(): IdentityRemovalConfirmation =
    790     IdentityRemovalConfirmation(
    791         identityId = identityId,
    792         requestId = requestId,
    793         deletesLocalCredential = deletesLocalCredential,
    794         signsOut = signsOut,
    795         expiresAt = expiresAt,
    796     )
    797 
    798 private fun HarvestCircleIntent.toApplicationCommand(): ApplicationCommand =
    799     when (this) {
    800         is HarvestCircleIntent.SelectIdentity -> ApplicationCommand.SelectIdentity(identityId)
    801         is HarvestCircleIntent.ActivateIdentity -> ApplicationCommand.ActivateIdentity(identityId)
    802         HarvestCircleIntent.SignOut -> ApplicationCommand.SignOut
    803         HarvestCircleIntent.RefreshActiveProfile -> ApplicationCommand.RefreshActiveProfile
    804         else -> error("Intent is not a direct application command")
    805     }
    806 
    807 private fun Throwable.toProblem(operationId: OperationId?): ApplicationProblem =
    808     (this as? ApplicationFailure)?.problem
    809         ?: ApplicationProblem(
    810             code = ApplicationErrorCode.Internal,
    811             category = ApplicationErrorCategory.Internal,
    812             retryable = false,
    813             recoveryAction = RecoveryAction.None,
    814             operationId = operationId,
    815             safeMessage = "The application command failed.",
    816         )
    817 
    818 private fun removalReleaseFailedProblem(): ApplicationProblem =
    819     ApplicationProblem(
    820         code = ApplicationErrorCode.InvalidApplicationState,
    821         category = ApplicationErrorCategory.Lifecycle,
    822         retryable = false,
    823         recoveryAction = RecoveryAction.RestartApplication,
    824         operationId = null,
    825         safeMessage = "The identity removal request could not be released safely.",
    826     )
    827 
    828 private fun removalExpiredProblem(operationId: OperationId? = null): ApplicationProblem =
    829     ApplicationProblem(
    830         code = ApplicationErrorCode.InvalidApplicationState,
    831         category = ApplicationErrorCategory.Lifecycle,
    832         retryable = false,
    833         recoveryAction = RecoveryAction.None,
    834         operationId = operationId,
    835         safeMessage = "Identity removal confirmation has expired.",
    836     )
    837 
    838 private val READY_ROUTES =
    839     setOf(
    840         HarvestCircleRoute.IDENTITIES,
    841         HarvestCircleRoute.ACTIVE_IDENTITY,
    842         HarvestCircleRoute.DEGRADED,
    843     )
    844 private const val EFFECT_BUFFER_CAPACITY = 8
    845 private const val COMMAND_DEADLINE_MILLIS = 5_000UL
    846 
    847 internal fun removalExpiryDelayMillis(
    848     expiresAt: UnixSeconds,
    849     now: UnixSeconds,
    850 ): Long {
    851     if (expiresAt.value <= now.value) return 0L
    852     val remainingSeconds =
    853         if (now.value < 0L && expiresAt.value > Long.MAX_VALUE + now.value) {
    854             Long.MAX_VALUE
    855         } else {
    856             expiresAt.value - now.value
    857         }
    858     return if (remainingSeconds > Long.MAX_VALUE / MILLIS_PER_SECOND) {
    859         Long.MAX_VALUE
    860     } else {
    861         remainingSeconds * MILLIS_PER_SECOND
    862     }
    863 }
    864 
    865 private const val MILLIS_PER_SECOND = 1_000L