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